From 2e3a7b1883f4e2c96b4aa751658e6fbb2273a715 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 14 Mar 2022 16:48:48 +0000 Subject: [PATCH 01/47] Add user profile UI --- package.json | 1 + x-pack/plugins/security/common/index.ts | 4 + x-pack/plugins/security/common/model/index.ts | 2 + x-pack/plugins/security/common/model/user.ts | 2 +- .../model}/user_profile.mock.ts | 0 .../security/common/model/user_profile.ts | 96 +++ .../account_management_app.ts | 50 -- .../account_management_app.tsx | 97 +++ .../account_management_page.tsx | 82 +-- .../public/account_management/index.ts | 3 - .../user_profile/form_changes.tsx | 43 ++ .../user_profile/form_field.tsx | 89 +++ .../user_profile/form_label.tsx | 33 + .../user_profile/form_row.tsx | 57 ++ .../account_management/user_profile/index.ts | 12 + .../user_profile/user_avatar.tsx | 44 ++ .../user_profile/user_profile.tsx | 590 ++++++++++++++++++ .../user_profile/user_profile_api_client.ts | 34 + .../account_management/user_profile/utils.ts | 72 +++ .../public/components/use_current_user.ts | 10 + .../edit_user/change_password_flyout.tsx | 70 ++- .../nav_control/nav_control_component.tsx | 282 ++++----- .../nav_control/nav_control_service.tsx | 41 +- .../server/routes/user_profile/get.ts | 17 +- .../server/routes/user_profile/update.ts | 14 +- .../server/user_profile/user_profile.ts | 45 -- .../user_profile/user_profile_service.mock.ts | 2 +- .../user_profile/user_profile_service.test.ts | 2 +- .../user_profile/user_profile_service.ts | 13 +- yarn.lock | 24 +- 30 files changed, 1429 insertions(+), 402 deletions(-) rename x-pack/plugins/security/{server/user_profile => common/model}/user_profile.mock.ts (100%) create mode 100644 x-pack/plugins/security/common/model/user_profile.ts delete mode 100644 x-pack/plugins/security/public/account_management/account_management_app.ts create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/form_field.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/form_label.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/form_row.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/index.ts create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts create mode 100644 x-pack/plugins/security/public/account_management/user_profile/utils.ts delete mode 100644 x-pack/plugins/security/server/user_profile/user_profile.ts diff --git a/package.json b/package.json index baf1103a8ef5c..b35491314a65e 100644 --- a/package.json +++ b/package.json @@ -242,6 +242,7 @@ "file-saver": "^1.3.8", "file-type": "^10.9.0", "font-awesome": "4.7.0", + "formik": "^2.2.9", "fp-ts": "^2.3.1", "geojson-vt": "^3.2.1", "get-port": "^5.0.0", diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index 0da855b153be8..9f8e7ffb04db5 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -17,6 +17,10 @@ export type { RoleKibanaPrivilege, FeaturesPrivileges, User, + UserProfile, + UserData, + UserAvatar, + UserInfo, ApiKey, UserRealm, } from './model'; diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 84d7f261e51a7..6f64f353e7728 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -7,6 +7,8 @@ export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; export type { User, EditUser } from './user'; +export type { UserProfile, UserData, UserInfo, UserAvatar } from './user_profile'; +export { getUserAvatarColor, getUserAvatarInitials } from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; export { canUserChangePassword } from './authenticated_user'; diff --git a/x-pack/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts index 2bcea659699cb..0501b265d0631 100644 --- a/x-pack/plugins/security/common/model/user.ts +++ b/x-pack/plugins/security/common/model/user.ts @@ -23,6 +23,6 @@ export interface EditUser extends User { confirmPassword?: string; } -export function getUserDisplayName(user: User) { +export function getUserDisplayName(user: Pick) { return user.full_name || user.username; } diff --git a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts b/x-pack/plugins/security/common/model/user_profile.mock.ts similarity index 100% rename from x-pack/plugins/security/server/user_profile/user_profile.mock.ts rename to x-pack/plugins/security/common/model/user_profile.mock.ts diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts new file mode 100644 index 0000000000000..c23db10ac2f0a --- /dev/null +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { VISUALIZATION_COLORS } from '@elastic/eui'; + +import type { User } from './user'; +import { getUserDisplayName } from './user'; + +export interface UserInfo extends User { + active: boolean; +} + +export interface UserAvatar { + initials?: string; + color?: string; + imageUrl?: string; +} + +export type UserData = Record; + +/** + * Describes properties of the user's profile. + */ +export interface UserProfile { + /** + * Unique ID for of the user profile. + */ + uid: string; + + /** + * Indicates whether user profile is enabled or not. + */ + enabled: boolean; + + /** + * Information about the user that owns profile. + */ + user: UserInfo; + + /** + * User specific data associated with the profile. + */ + data: T; +} + +const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" +const USER_AVATAR_MAX_INITIALS = 2; + +/** + * Determines the color for the provided user profile. + * If a color is present on the user profile itself, then that is used. + * Otherwise, a color is provided from EUI's Visualization Colors based on the display name. + * + * @param {UserInfo} user User info + * @param {UserAvatar} avatar User avatar + */ +export function getUserAvatarColor( + user: Pick, + avatar?: UserAvatar +) { + if (avatar && avatar.color) { + return avatar.color; + } + + const firstCodePoint = getUserDisplayName(user).codePointAt(0) || USER_AVATAR_FALLBACK_CODE_POINT; + + return VISUALIZATION_COLORS[firstCodePoint % VISUALIZATION_COLORS.length]; +} + +/** + * Determines the initials for the provided user profile. + * If initials are present on the user profile itself, then that is used. + * Otherwise, the initials are calculated based off the words in the display name, with a max length of 2 characters. + * + * @param {UserInfo} user User info + * @param {UserAvatar} avatar User avatar + */ +export function getUserAvatarInitials( + user: Pick, + avatar?: UserAvatar +) { + if (avatar && avatar.initials) { + return avatar.initials; + } + + const words = getUserDisplayName(user).split(' '); + const numInitials = Math.min(USER_AVATAR_MAX_INITIALS, words.length); + + words.splice(numInitials, words.length); + + return words.map((word) => word.substring(0, 1)).join(''); +} diff --git a/x-pack/plugins/security/public/account_management/account_management_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts deleted file mode 100644 index d95b86194f54f..0000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.ts +++ /dev/null @@ -1,50 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import type { ApplicationSetup, AppMountParameters, StartServicesAccessor } from 'src/core/public'; - -import { AppNavLinkStatus } from '../../../../../src/core/public'; -import type { AuthenticationServiceSetup } from '../authentication'; - -interface CreateDeps { - application: ApplicationSetup; - authc: AuthenticationServiceSetup; - getStartServices: StartServicesAccessor; -} - -export const accountManagementApp = Object.freeze({ - id: 'security_account', - create({ application, authc, getStartServices }: CreateDeps) { - const title = i18n.translate('xpack.security.account.breadcrumb', { - defaultMessage: 'Account Management', - }); - application.register({ - id: this.id, - title, - navLinkStatus: AppNavLinkStatus.hidden, - appRoute: '/security/account', - async mount({ element, theme$ }: AppMountParameters) { - const [[coreStart], { renderAccountManagementPage }, { UserAPIClient }] = await Promise.all( - [getStartServices(), import('./account_management_page'), import('../management')] - ); - - coreStart.chrome.setBreadcrumbs([{ text: title }]); - - return renderAccountManagementPage( - coreStart.i18n, - { element, theme$ }, - { - authc, - notifications: coreStart.notifications, - userAPIClient: new UserAPIClient(coreStart.http), - } - ); - }, - }); - }, -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx new file mode 100644 index 0000000000000..9e9f305146d74 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { History } from 'history'; +import type { FunctionComponent } from 'react'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Router } from 'react-router-dom'; +import type { Observable } from 'rxjs'; + +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n-react'; +import type { + ApplicationSetup, + AppMountParameters, + CoreStart, + CoreTheme, + StartServicesAccessor, +} from 'src/core/public'; + +import { AppNavLinkStatus } from '../../../../../src/core/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../src/plugins/kibana_react/public'; +import type { AuthenticationServiceSetup } from '../authentication'; +import type { BreadcrumbsChangeHandler } from '../components/breadcrumb'; +import { BreadcrumbsProvider } from '../components/breadcrumb'; +import { AuthenticationProvider } from '../components/use_current_user'; + +interface CreateDeps { + application: ApplicationSetup; + authc: AuthenticationServiceSetup; + getStartServices: StartServicesAccessor; +} + +export const accountManagementApp = Object.freeze({ + id: 'security_account', + create({ application, authc, getStartServices }: CreateDeps) { + application.register({ + id: this.id, + title: i18n.translate('xpack.security.account.breadcrumb', { + defaultMessage: 'User settings', + }), + navLinkStatus: AppNavLinkStatus.hidden, + appRoute: '/security/account', + async mount({ element, theme$, history }: AppMountParameters) { + const [[coreStart], { AccountManagementPage }] = await Promise.all([ + getStartServices(), + import('./account_management_page'), + ]); + + render( + + + , + element + ); + + return () => unmountComponentAtNode(element); + }, + }); + }, +}); + +export interface ProvidersProps { + services: CoreStart; + theme$: Observable; + history: History; + authc: AuthenticationServiceSetup; + onChange?: BreadcrumbsChangeHandler; +} + +export const Providers: FunctionComponent = ({ + services, + theme$, + history, + authc, + onChange, + children, +}) => ( + + + + + + {children} + + + + + +); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index bc5229ba6bc9f..604f01b04d249 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -5,80 +5,28 @@ * 2.0. */ -import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; +import type { FunctionComponent } from 'react'; +import React from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { PublicMethodsOf } from '@kbn/utility-types'; -import type { AppMountParameters, CoreStart, NotificationsStart } from 'src/core/public'; - -import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; -import type { AuthenticatedUser } from '../../common/model'; import { getUserDisplayName } from '../../common/model'; -import type { AuthenticationServiceSetup } from '../authentication'; -import type { UserAPIClient } from '../management'; -import { ChangePassword } from './change_password'; -import { PersonalInfo } from './personal_info'; - -interface Props { - authc: AuthenticationServiceSetup; - userAPIClient: PublicMethodsOf; - notifications: NotificationsStart; -} +import { Breadcrumb } from '../components/breadcrumb'; +import { useCurrentUser, useUserProfile } from '../components/use_current_user'; +import type { UserProfileProps } from './user_profile'; +import { UserProfile } from './user_profile'; -export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => { - const [currentUser, setCurrentUser] = useState(null); - useEffect(() => { - authc.getCurrentUser().then(setCurrentUser); - }, [authc]); +export const AccountManagementPage: FunctionComponent = () => { + const currentUser = useCurrentUser(); + const userProfile = useUserProfile>('avatar'); - if (!currentUser) { + if (!currentUser.value || !userProfile.value) { return null; } - return ( - - - - -

- {getUserDisplayName(currentUser)} }} - /> -

-
- - - - + const displayName = getUserDisplayName(userProfile.value.user); - -
-
-
+ return ( + + + ); }; - -export function renderAccountManagementPage( - i18nStart: CoreStart['i18n'], - { element, theme$ }: Pick, - props: Props -) { - ReactDOM.render( - - - - - , - element - ); - - return () => ReactDOM.unmountComponentAtNode(element); -} diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index 2d1045723a6e1..bfba213c632d0 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,6 +6,3 @@ */ export { accountManagementApp } from './account_management_app'; - -export type { ChangePasswordProps } from './change_password'; -export type { PersonalInfoProps } from './personal_info'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx new file mode 100644 index 0000000000000..b0ddfa6f7ed5a --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createContext, useContext, useState } from 'react'; + +export interface FormChanges { + count: number; + register(isEqual: boolean): undefined | (() => void); +} + +export const useFormChanges = (): FormChanges => { + const [count, setCount] = useState(0); + + return { + count, + register: (isEqual: boolean) => { + if (!isEqual) { + setCount((c) => c + 1); + return () => setCount((c) => c - 1); + } + }, + }; +}; + +export const FormChangesContext = createContext(undefined); + +export const FormChangesProvider = FormChangesContext.Provider; + +export function useFormChangesContext() { + const context = useContext(FormChangesContext); + + if (!context) { + throw new Error( + 'FormChanges context is undefined, please verify you are calling useFormChangesContext() as child of a component.' + ); + } + + return context; +} diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx new file mode 100644 index 0000000000000..13ee4fb8a1ebe --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useField } from 'formik'; +import type { FieldValidator } from 'formik'; +import React from 'react'; + +export interface FormFieldProps { + as?: T; + name: string; + validate?: FieldValidator | ValidateOptions; +} + +export interface ValidateOptions { + required?: string; + minLength?: { + value: number; + message: string; + }; + maxLength?: { + value: number; + message: string; + }; + min?: { + value: number; + message: string; + }; + max?: { + value: number; + message: string; + }; + pattern?: { + value: RegExp; + message: string; + }; +} + +export function FormField({ + as, + validate, + onBlur, + ...rest +}: FormFieldProps & Omit, keyof FormFieldProps>) { + const Component = as || 'input'; + + const [field, meta, helpers] = useField({ + name: rest.name, + validate: typeof validate === 'object' ? createFieldValidator(validate) : validate, + }); + + return ( + { + onBlur?.(event); + helpers.setTouched(true); // Marking as touched manually here since some EUI fields don't pass on the correct `event` argument causing errors when `field.onBlur(event)` is called as a result of spreading `field` props. + }} + /> + ); +} + +export function createFieldValidator(options: ValidateOptions): FieldValidator { + return (value: any) => { + if (options.required && !value) { + return options.required; + } + if (options.minLength && value.length < options.minLength.value) { + return options.minLength.message; + } + if (options.maxLength && value.length > options.maxLength.value) { + return options.maxLength.message; + } + if (options.min && value.length < options.min.value) { + return options.min.message; + } + if (options.max && value.length > options.max.value) { + return options.max.message; + } + if (options.pattern && !options.pattern.value.test(value)) { + return options.pattern.message; + } + }; +} diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx new file mode 100644 index 0000000000000..2fca9bb59c9c8 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { useEffect } from 'react'; + +import { useFormChangesContext } from './form_changes'; + +export interface FormLabelProps { + isEqual: boolean; +} + +export const FormLabel: FunctionComponent = ({ isEqual, children }) => { + const { register } = useFormChangesContext(); + + useEffect(() => register(isEqual), [isEqual]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {children} + {!isEqual ? ( + + + + ) : undefined} + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx new file mode 100644 index 0000000000000..f5f03d8b23855 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow, EuiText } from '@elastic/eui'; +import type { EuiFormRowProps } from '@elastic/eui'; +import { useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { Children } from 'react'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { FormLabel } from './form_label'; + +/** + * Same as @see EuiFormRow but automatically renders `error` and `isInvalid` states based on @see FormikContext. + */ +export const FormRow: FunctionComponent = (props) => { + const formik = useFormikContext(); + const child = Children.only(props.children); + const meta = formik.getFieldMeta(props.name ?? child.props.name); + + const isOptional = !child.props.validate || !child.props.validate.required; + const isDisabled = props.isDisabled || child.props.disabled; + + return ( + {props.label} + ) : undefined + } + labelAppend={ + !props.labelAppend && isOptional && !isDisabled ? : props.labelAppend + } + > + {child} + + ); +}; + +export const OptionalText: FunctionComponent = () => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/index.ts b/x-pack/plugins/security/public/account_management/user_profile/index.ts new file mode 100644 index 0000000000000..1fd578a929e90 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { UserProfile } from './user_profile'; +export { UserAvatar } from './user_avatar'; + +export type { UserProfileProps, UserProfileFormValues } from './user_profile'; +export type { UserAvatarProps } from './user_avatar'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx new file mode 100644 index 0000000000000..ad6a1b512066f --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiAvatarProps } from '@elastic/eui'; +import { EuiAvatar } from '@elastic/eui'; +import type { FunctionComponent, HTMLAttributes } from 'react'; +import React from 'react'; + +import type { UserAvatar as IUserAvatar, UserInfo } from '../../../common'; +import { + getUserAvatarColor, + getUserAvatarInitials, + getUserDisplayName, +} from '../../../common/model'; + +export interface UserAvatarProps extends Omit, 'color'> { + user: Pick; + avatar?: IUserAvatar; + size?: EuiAvatarProps['size']; + isDisabled?: EuiAvatarProps['isDisabled']; +} + +export const UserAvatar: FunctionComponent = ({ user, avatar, ...rest }) => { + const displayName = getUserDisplayName(user); + const color = getUserAvatarColor(user, avatar); + + if (avatar && avatar.imageUrl) { + return ; + } + + return ( + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx new file mode 100644 index 0000000000000..5c899629a1389 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -0,0 +1,590 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiColorPicker, + EuiDescribedFormGroup, + EuiFieldText, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiKeyPadMenu, + EuiKeyPadMenuItem, + EuiPageTemplate, + EuiSpacer, + EuiSplitPanel, + EuiToolTip, +} from '@elastic/eui'; +import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { CoreStart } from 'src/core/public'; + +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; +import { + canUserChangePassword, + getUserAvatarColor, + getUserAvatarInitials, +} from '../../../common/model'; +import { Breadcrumb } from '../../components/breadcrumb'; +import { UserAPIClient } from '../../management/users'; +import { ChangePasswordFlyout } from '../../management/users/edit_user/change_password_flyout'; +import { isUserReserved } from '../../management/users/user_utils'; +import { FormChangesProvider, useFormChanges, useFormChangesContext } from './form_changes'; +import { FormField } from './form_field'; +import { FormLabel } from './form_label'; +import { FormRow } from './form_row'; +import { UserAvatar } from './user_avatar'; +import { UserProfileAPIClient } from './user_profile_api_client'; +import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; + +export interface UserProfileProps { + user: AuthenticatedUser; + data: { + avatar?: IUserAvatar; + }; +} + +export interface UserProfileFormValues { + user: { + full_name: string; + email: string; + }; + data: { + avatar: { + initials: string; + color: string; + imageUrl: string; + }; + }; + avatarType: 'initials' | 'image'; + customAvatarInitials: boolean; + customAvatarColor: boolean; +} + +export interface UserProfileFormStatus { + changes: any; + numChanges: number; +} + +export const UserProfile: FunctionComponent = ({ user, data }) => { + const { services } = useKibana(); + const [initialValues, resetInitialValues] = useState({ + user: { + full_name: user.full_name || '', + email: user.email || '', + }, + data: { + avatar: { + initials: data.avatar?.initials || getUserAvatarInitials(user), + color: data.avatar?.color || getUserAvatarColor(user), + imageUrl: data.avatar?.imageUrl ?? '', + }, + }, + avatarType: data.avatar?.imageUrl ? 'image' : 'initials', + customAvatarInitials: + !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user), + customAvatarColor: !!data.avatar?.color && data.avatar?.color !== getUserAvatarColor(user), + }); + + const formik = useFormik({ + onSubmit: async (values) => { + try { + await Promise.all([ + new UserAPIClient(services.http).saveUser({ + username: user.username, + roles: user.roles, + enabled: user.enabled, + full_name: values.user.full_name, + email: values.user.email, + }), + new UserProfileAPIClient(services.http).update( + values.avatarType === 'image' + ? values.data + : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } + ), + ]); + resetInitialValues(values); + services.notifications.toasts.addSuccess( + i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + defaultMessage: 'Profile updated', + }) + ); + } catch (error) { + services.notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + defaultMessage: "Couldn't update profile", + }), + }); + } + }, + initialValues, + initialStatus: { + changes: {}, + numChanges: 0, + }, + enableReinitialize: true, + }); + + const formChanges = useFormChanges(); + + useUpdateEffect(() => { + if (!formik.values.customAvatarInitials) { + formik.setFieldValue( + 'data.avatar.initials', + getUserAvatarInitials({ username: user.username, full_name: formik.values.user.full_name }) + ); + } + + if (!formik.values.customAvatarColor) { + formik.setFieldValue( + 'data.avatar.color', + getUserAvatarColor({ username: user.username, full_name: formik.values.user.full_name }) + ); + } + }, [formik.values.user.full_name]); + + useUpdateEffect(() => { + formik.setFieldValue( + 'customAvatarInitials', + formik.values.data.avatar.initials !== getUserAvatarInitials(user) + ); + }, [formik.values.data.avatar.initials]); + + useUpdateEffect(() => { + formik.setFieldValue( + 'customAvatarColor', + formik.values.data.avatar.color !== getUserAvatarColor(user) + ); + }, [formik.values.data.avatar.color]); + + const [showDeleteFlyout, setShowDeleteFlyout] = useState(false); + + const isReservedUser = isUserReserved(user); + const canChangePassword = canUserChangePassword(user); + + return ( + + + + {showDeleteFlyout ? ( + setShowDeleteFlyout(false)} + /> + ) : null} + + + ), + rightSideItems: [ + + } + > + setShowDeleteFlyout(true)} + iconType="lock" + isDisabled={!canChangePassword} + fill + > + + + , + ], + }} + bottomBar={formChanges.count > 0 ? : null} + restrictWidth={900} + > + {isReservedUser ? ( + <> + + } + iconType="lock" + /> + + + ) : null} + +
+ + + + } + description={ + + } + > + {isReservedUser ? ( + + } + helpText={ + !isReservedUser ? ( + + ) : null + } + fullWidth + isDisabled + > + + + ) : null} + + + } + isDisabled={isReservedUser} + fullWidth + > + + + + + } + isDisabled={isReservedUser} + fullWidth + > + + + + + + + } + description={ + + } + > + + + + + ), + }} + > + + } + onChange={() => formik.setFieldValue('avatarType', 'initials')} + isSelected={formik.values.avatarType === 'initials'} + isDisabled={isReservedUser} + > + + + + } + onChange={() => formik.setFieldValue('avatarType', 'image')} + isSelected={formik.values.avatarType === 'image'} + isDisabled={isReservedUser} + > + + + + + + + + + + + + {formik.values.avatarType === 'image' ? ( + + } + isDisabled={isReservedUser} + fullWidth + > + + } + onChange={createImageHandler((imageUrl) => { + formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); + })} + validate={{ + required: i18n.translate( + 'xpack.security.accountManagement.userProfile.initialsRequiredError', + { + defaultMessage: 'Upload an image.', + } + ), + }} + accept={IMAGE_FILE_TYPES.join(',')} + fullWidth + /> + + ) : ( + <> + + } + isDisabled={isReservedUser} + fullWidth + > + + + + + } + labelAppend={ + !isReservedUser ? ( + + formik.setFieldValue('data.avatar.color', getRandomColor()) + } + size="xs" + flush="right" + style={{ height: 18 }} + > + + + ) : null + } + isDisabled={isReservedUser} + fullWidth + > + { + formik.setFieldValue('data.avatar.color', value); + }} + fullWidth + /> + + + )} + + + +
+ +
+
+
+
+ ); +}; + +export const SaveChangesBottomBar: FunctionComponent = () => { + const formik = useFormikContext(); + const { count } = useFormChangesContext(); + + return ( + + + + + + + + + + + + + + + + + + 0 && !formik.isValid} + color="success" + iconType="save" + fill + > + + + + + ); +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts new file mode 100644 index 0000000000000..588b20f0a3042 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpStart } from 'src/core/public'; + +import type { UserData, UserProfile } from '../../../common/model'; + +const USER_PROFILE_URL = '/internal/security/user_profile'; + +export class UserProfileAPIClient { + constructor(private readonly http: HttpStart) {} + + /** + * Retrieves the user profile of the current user. + * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. + */ + public get(dataPath?: string) { + return this.http.get>(USER_PROFILE_URL, { query: { data: dataPath } }); + } + + /** + * Updates user preferences of the current user. + * @param data Application data to be written (merged with existing data). + */ + public update(data: T) { + return this.http.post(`${USER_PROFILE_URL}/_data`, { + body: JSON.stringify(data), + }); + } +} diff --git a/x-pack/plugins/security/public/account_management/user_profile/utils.ts b/x-pack/plugins/security/public/account_management/user_profile/utils.ts new file mode 100644 index 0000000000000..639c898273931 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/utils.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const IMAGE_FILE_TYPES = ['image/svg+xml', 'image/jpeg', 'image/png', 'image/gif']; +export const MAX_IMAGE_SIZE = 64; + +export function readFile(data: File) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(data); + }); +} + +export function resizeImage(imageUrl: string, maxSize: number) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + if (image.width <= maxSize && image.height <= maxSize) { + return resolve(imageUrl); + } + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context) { + if (image.width >= image.height) { + canvas.width = maxSize; + canvas.height = Math.floor((image.height * maxSize) / image.width); + } else { + canvas.height = maxSize; + canvas.width = Math.floor((image.width * maxSize) / image.height); + } + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const resizedDataUrl = canvas.toDataURL(); + return resolve(resizedDataUrl); + } + + return reject(); + }; + image.onerror = reject; + image.src = imageUrl; + }); +} + +export function createImageHandler(callback: (imageUrl: string | undefined) => void) { + return async (files: FileList | null) => { + if (!files || !files.length) { + callback(undefined); + return; + } + const file = files[0]; + if (IMAGE_FILE_TYPES.indexOf(file.type) !== -1) { + const imageUrl = await readFile(file); + const resizedImageUrl = await resizeImage(imageUrl, MAX_IMAGE_SIZE); + callback(resizedImageUrl); + } + }; +} + +/** + * Returns the hex representation of a random color (e.g `#F1B7E2`) + */ +export function getRandomColor() { + return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0'); +} + +export const VALID_HEX_COLOR = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 103952d7d34ef..65744ea9daab0 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -8,6 +8,11 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; +import type { CoreStart } from 'src/core/public'; + +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; +import type { UserAvatar, UserData } from '../../common'; +import { UserProfileAPIClient } from '../account_management/user_profile/user_profile_api_client'; import type { AuthenticationServiceSetup } from '../authentication'; export interface AuthenticationProviderProps { @@ -24,3 +29,8 @@ export function useCurrentUser() { const authc = useAuthentication(); return useAsync(authc.getCurrentUser, [authc]); } + +export function useUserProfile(dataPath = 'avatar') { + const { services } = useKibana(); + return useAsync(() => new UserProfileAPIClient(services.http).get(dataPath), [services.http]); +} diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx index 2ad27a77c07b8..9b53e06bd318e 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx @@ -208,46 +208,48 @@ export const ChangePasswordFlyout: FunctionComponent ) : undefined} - - - - - - - - {username} - - - - - {isCurrentUser ? ( + <> + + + + + ) : ( - + + + + + + + {username} + + + - ) : null} + )} ; +interface SecurityNavControlProps { editProfileUrl: string; logoutUrl: string; userMenuLinks$: Observable; } -interface State { - isOpen: boolean; - authenticatedUser: AuthenticatedUser | null; - userMenuLinks: UserMenuLink[]; -} - -export class SecurityNavControl extends Component { - private subscription?: Subscription; - - constructor(props: Props) { - super(props); - - this.state = { - isOpen: false, - authenticatedUser: null, - userMenuLinks: [], - }; - - props.user.then((authenticatedUser) => { - this.setState({ - authenticatedUser, - }); - }); - } - - componentDidMount() { - this.subscription = this.props.userMenuLinks$.subscribe(async (userMenuLinks) => { - this.setState({ userMenuLinks }); - }); - } - - componentWillUnmount() { - if (this.subscription) { - this.subscription.unsubscribe(); - } - } - - onMenuButtonClick = () => { - if (!this.state.authenticatedUser) { - return; - } - - this.setState({ - isOpen: !this.state.isOpen, - }); - }; - - closeMenu = () => { - this.setState({ - isOpen: false, - }); - }; - - render() { - const { editProfileUrl, logoutUrl } = this.props; - const { authenticatedUser, userMenuLinks } = this.state; - - const username = - (authenticatedUser && (authenticatedUser.full_name || authenticatedUser.username)) || ''; - - const buttonContents = authenticatedUser ? ( - - ) : ( - - ); - - const button = ( - - {buttonContents} - - ); - - const isAnonymousUser = authenticatedUser?.authentication_provider.type === 'anonymous'; - const items: EuiContextMenuPanelItemDescriptor[] = []; - - if (userMenuLinks.length) { - const userMenuLinkMenuItems = userMenuLinks - .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href }: UserMenuLink) => ({ - name: label, - icon: , - href, - 'data-test-subj': `userMenuLink__${label}`, - })); - items.push(...userMenuLinkMenuItems); - } - - if (!isAnonymousUser) { - const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); - const profileMenuItem = { - name: ( - - ), - icon: , - href: editProfileUrl, - 'data-test-subj': 'profileLink', - }; - - // Set this as the first link if there is no user-defined profile link - if (!hasCustomProfileLinks) { - items.unshift(profileMenuItem); - } else { - items.push(profileMenuItem); - } - } - - const logoutMenuItem = { - name: isAnonymousUser ? ( - = ({ + editProfileUrl, + logoutUrl, + userMenuLinks$, +}) => { + const userMenuLinks = useObservable(userMenuLinks$) ?? []; + const [isOpen, setIsOpen] = useState(false); + + const userProfile = useUserProfile(); + + const displayName = userProfile.value ? getUserDisplayName(userProfile.value.user) : ''; + + const button = ( + setIsOpen((value) => (userProfile.value ? !value : false))} + data-test-subj="userMenuButton" + > + {userProfile.value ? ( + ) : ( + + )} + + ); + + const isAnonymousUser = false; // authenticatedUser?.authentication_provider.type === 'anonymous'; + const items: EuiContextMenuPanelItemDescriptor[] = []; + + if (userMenuLinks.length) { + const userMenuLinkMenuItems = userMenuLinks + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) + .map(({ label, iconType, href }: UserMenuLink) => ({ + name: label, + icon: , + href, + 'data-test-subj': `userMenuLink__${label}`, + })); + items.push(...userMenuLinkMenuItems); + } + + if (!isAnonymousUser) { + const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); + const profileMenuItem = { + name: ( ), - icon: , - href: logoutUrl, - 'data-test-subj': 'logoutLink', + icon: , + href: editProfileUrl, + 'data-test-subj': 'profileLink', }; - items.push(logoutMenuItem); - - const panels = [ - { - id: 0, - title: username, - items, - }, - ]; - - return ( - -
- -
-
- ); + + // Set this as the first link if there is no user-defined profile link + if (!hasCustomProfileLinks) { + items.unshift(profileMenuItem); + } else { + items.push(profileMenuItem); + } } -} + + const logoutMenuItem = { + name: isAnonymousUser ? ( + + ) : ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }; + items.push(logoutMenuItem); + + const panels = [ + { + id: 0, + title: displayName, + items, + }, + ]; + + return ( + setIsOpen(false)} + panelPaddingSize="none" + buffer={0} + > +
+ +
+
+ ); +}; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 0db60c83f55d7..21cf0116a29f6 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -12,9 +12,13 @@ import type { Observable, Subscription } from 'rxjs'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; +import { I18nProvider } from '@kbn/i18n-react'; import type { CoreStart } from 'src/core/public'; -import { KibanaThemeProvider } from '../../../../../src/plugins/kibana_react/public'; +import { + KibanaContextProvider, + KibanaThemeProvider, +} from '../../../../../src/plugins/kibana_react/public'; import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceSetup } from '../authentication'; import type { UserMenuLink } from './nav_control_component'; @@ -44,7 +48,6 @@ export interface SecurityNavControlServiceStart { export class SecurityNavControlService { private securityLicense!: SecurityLicense; - private authc!: AuthenticationServiceSetup; private logoutUrl!: string; private navControlRegistered!: boolean; @@ -54,9 +57,8 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { + public setup({ securityLicense, logoutUrl }: SetupDeps) { this.securityLicense = securityLicense; - this.authc = authc; this.logoutUrl = logoutUrl; } @@ -114,28 +116,25 @@ export class SecurityNavControlService { core: Pick ) { const { theme$ } = core.theme; - const currentUserPromise = this.authc.getCurrentUser(); core.chrome.navControls.registerRight({ order: 2000, - mount: (el: HTMLElement) => { - const I18nContext = core.i18n.Context; - - const props = { - user: currentUserPromise, - editProfileUrl: core.http.basePath.prepend('/security/account'), - logoutUrl: this.logoutUrl, - userMenuLinks$: this.userMenuLinks$, - }; + mount: (element: HTMLElement) => { ReactDOM.render( - - - - - , - el + + + + + + + , + element ); - return () => ReactDOM.unmountComponentAtNode(el); + return () => ReactDOM.unmountComponentAtNode(element); }, }); diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 9bfa2fe16a682..232f0161a1df4 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -13,21 +13,30 @@ import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineGetUserProfileRoute({ router, + getSession, getUserProfileService, }: RouteDefinitionParams) { router.get( { - path: '/internal/security/user_profile/{uid}', + path: '/internal/security/user_profile', options: { tags: ['access:accessUserProfile'] }, validate: { - params: schema.object({ uid: schema.string() }), + query: schema.object({ data: schema.maybe(schema.string()) }), }, }, createLicensedRouteHandler(async (context, request, response) => { + const session = await getSession().get(request); + if (!session) { + return response.unauthorized(); + } + if (!session.userProfileId) { + return response.notFound(); + } + const userProfileService = getUserProfileService(); try { - const profile = await userProfileService.get(request.params.uid, '*'); - return response.ok({ body: profile }); + const profile = await userProfileService.get(session.userProfileId, request.query.data); + return response.ok({ body: { ...profile, provider: session.provider } }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); } diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 00cbfea3a957b..9484d7d2c3240 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -13,21 +13,29 @@ import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineUpdateUserProfileDataRoute({ router, + getSession, getUserProfileService, }: RouteDefinitionParams) { router.post( { - path: '/internal/security/user_profile/_data/{uid}', + path: '/internal/security/user_profile/_data', options: { tags: ['access:updateUserProfile'] }, validate: { - params: schema.object({ uid: schema.string() }), body: schema.recordOf(schema.string(), schema.any()), }, }, createLicensedRouteHandler(async (context, request, response) => { + const session = await getSession().get(request); + if (!session) { + return response.unauthorized(); + } + if (!session.userProfileId) { + return response.notFound(); + } + const userProfileService = getUserProfileService(); try { - await userProfileService.update(request.params.uid, request.body); + await userProfileService.update(request, session.userProfileId, request.body); return response.ok(); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); diff --git a/x-pack/plugins/security/server/user_profile/user_profile.ts b/x-pack/plugins/security/server/user_profile/user_profile.ts deleted file mode 100644 index 0525cacc10614..0000000000000 --- a/x-pack/plugins/security/server/user_profile/user_profile.ts +++ /dev/null @@ -1,45 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { User } from '../../common'; - -export interface UserInfo extends User { - display_name?: string; - avatar?: { - initials?: string; - color?: string; - image_url?: string; - }; - active: boolean; -} - -export type UserData = Record; - -/** - * Describes properties of the user's profile. - */ -export interface UserProfile> { - /** - * Unique ID for of the user profile. - */ - uid: string; - - /** - * Indicates whether user profile is enabled or not. - */ - enabled: boolean; - - /** - * Information about the user that owns profile. - */ - user: UserInfo; - - /** - * User specific data associated with the profile. - */ - data: T; -} diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts index fa7623d40d362..4ccf0e91a3d80 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts @@ -5,8 +5,8 @@ * 2.0. */ +import { userProfileMock } from '../../common/model/user_profile.mock'; import type { UserProfileServiceStart } from '../user_profile'; -import { userProfileMock } from './user_profile.mock'; export const userProfileServiceMock = { createStart: (): jest.Mocked => ({ diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index e05d72e69b93f..e2bdac6940dd0 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -9,8 +9,8 @@ import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { userProfileMock } from '../../common/model/user_profile.mock'; import { securityMock } from '../mocks'; -import { userProfileMock } from './user_profile.mock'; import { UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index dbdc6d17e1581..c0c5850e0c8d6 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,10 +5,10 @@ * 2.0. */ -import type { IClusterClient, Logger } from 'src/core/server'; +import type { IClusterClient, KibanaRequest, Logger } from 'src/core/server'; +import type { UserData, UserInfo, UserProfile } from '../../common'; import { getDetailedErrorMessage } from '../errors'; -import type { UserData, UserInfo, UserProfile } from './user_profile'; import type { UserProfileGrant } from './user_profile_grant'; const KIBANA_DATA_ROOT = 'kibana'; @@ -29,10 +29,11 @@ export interface UserProfileServiceStart { /** * Updates user preferences by identifier. + * @param request Kibana request object * @param uid User ID * @param data Application data to be written (merged with existing data). */ - update(uid: string, data: T): Promise; + update(request: KibanaRequest, uid: string, data: T): Promise; } type GetProfileResponse = Record< @@ -90,16 +91,16 @@ export class UserProfileService { }`, }); const { user, enabled, data } = body[uid]; - return { uid, enabled, user, data: data[KIBANA_DATA_ROOT] }; + return { uid, enabled, user, data: data[KIBANA_DATA_ROOT] ?? {} }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); throw error; } } - async function update(uid: string, data: T) { + async function update(request: KibanaRequest, uid: string, data: T) { try { - await clusterClient.asInternalUser.transport.request({ + await clusterClient.asScoped(request).asCurrentUser.transport.request({ method: 'POST', path: `_security/profile/_data/${uid}`, body: { diff --git a/yarn.lock b/yarn.lock index 6867f4edc8191..0295444c3b527 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12889,7 +12889,7 @@ deep-object-diff@^1.1.0: resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.0.tgz#d6fabf476c2ed1751fc94d5ca693d2ed8c18bc5a" integrity sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw== -deepmerge@3.2.0, deepmerge@^4.0.0, deepmerge@^4.2.2: +deepmerge@3.2.0, deepmerge@^2.1.1, deepmerge@^4.0.0, deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== @@ -15518,6 +15518,19 @@ formidable@^1.2.0: resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.2.tgz#bf69aea2972982675f00865342b982986f6b8dd9" integrity sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q== +formik@^2.2.9: + version "2.2.9" + resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0" + integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA== + dependencies: + deepmerge "^2.1.1" + hoist-non-react-statics "^3.3.0" + lodash "^4.17.21" + lodash-es "^4.17.21" + react-fast-compare "^2.0.1" + tiny-warning "^1.0.2" + tslib "^1.10.0" + forwarded-parse@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/forwarded-parse/-/forwarded-parse-2.1.0.tgz#1ae9d7a4be3af884f74d936d856f7d8c6abd0439" @@ -19910,6 +19923,11 @@ lodash-es@^4.17.11: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== +lodash-es@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -24544,7 +24562,7 @@ react-error-overlay@^6.0.9: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== -react-fast-compare@^2.0.4: +react-fast-compare@^2.0.1, react-fast-compare@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw== @@ -28549,7 +28567,7 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== -tiny-warning@^1.0.0, tiny-warning@^1.0.3: +tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== From 98eb7c7ba2ed185c2c8ccee6b602e3fd75b6d637 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 14 Mar 2022 21:13:38 +0000 Subject: [PATCH 02/47] add code doc --- .../user_profile/form_field.tsx | 71 ++++++++++++------- .../user_profile/form_row.tsx | 19 ++++- .../user_profile/user_profile.tsx | 14 +--- 3 files changed, 64 insertions(+), 40 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx index 13ee4fb8a1ebe..3faf4ace6c304 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiFieldText } from '@elastic/eui'; import { useField } from 'formik'; import type { FieldValidator } from 'formik'; import React from 'react'; @@ -15,37 +16,31 @@ export interface FormFieldProps { validate?: FieldValidator | ValidateOptions; } -export interface ValidateOptions { - required?: string; - minLength?: { - value: number; - message: string; - }; - maxLength?: { - value: number; - message: string; - }; - min?: { - value: number; - message: string; - }; - max?: { - value: number; - message: string; - }; - pattern?: { - value: RegExp; - message: string; - }; -} - -export function FormField({ +/** + * Renders a field inside with correct inline validation states. + * + * @example Text field with validation rule: + * ```typescript + * + * ``` + * + * @example Color picker which uses non-standard value prop and change handler: + * ```typescript + * formik.setFieldValue('color', value)} + * /> + * ``` + */ +export function FormField({ as, validate, onBlur, ...rest }: FormFieldProps & Omit, keyof FormFieldProps>) { - const Component = as || 'input'; + const Component = as || EuiFieldText; const [field, meta, helpers] = useField({ name: rest.name, @@ -65,6 +60,30 @@ export function FormField({ ); } +export interface ValidateOptions { + required?: string; + minLength?: { + value: number; + message: string; + }; + maxLength?: { + value: number; + message: string; + }; + min?: { + value: number; + message: string; + }; + max?: { + value: number; + message: string; + }; + pattern?: { + value: RegExp; + message: string; + }; +} + export function createFieldValidator(options: ValidateOptions): FieldValidator { return (value: any) => { if (options.required && !value) { diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx index f5f03d8b23855..34d23720238c4 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx @@ -16,12 +16,27 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FormLabel } from './form_label'; /** - * Same as @see EuiFormRow but automatically renders `error` and `isInvalid` states based on @see FormikContext. + * Renders a form row with correct error states, change indicator and optional indicator. + * + * @example Renders as optional since form field has no validation rule. + * ```typescript + * + * + * + * ``` */ export const FormRow: FunctionComponent = (props) => { const formik = useFormikContext(); const child = Children.only(props.children); - const meta = formik.getFieldMeta(props.name ?? child.props.name); + const name = props.name ?? child.props.name; + + if (!name) { + throw new Error( + 'name prop is undefined, please verify you are rendering either itself or its direct child with a name prop to correctly identify the field.' + ); + } + + const meta = formik.getFieldMeta(name); const isOptional = !child.props.validate || !child.props.validate.required; const isDisabled = props.isDisabled || child.props.disabled; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 5c899629a1389..4a52d0555dec7 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -76,11 +76,6 @@ export interface UserProfileFormValues { customAvatarColor: boolean; } -export interface UserProfileFormStatus { - changes: any; - numChanges: number; -} - export const UserProfile: FunctionComponent = ({ user, data }) => { const { services } = useKibana(); const [initialValues, resetInitialValues] = useState({ @@ -133,10 +128,6 @@ export const UserProfile: FunctionComponent = ({ user, data }) } }, initialValues, - initialStatus: { - changes: {}, - numChanges: 0, - }, enableReinitialize: true, }); @@ -293,7 +284,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) isDisabled={isReservedUser} fullWidth > - + = ({ user, data }) isDisabled={isReservedUser} fullWidth > - + = ({ user, data }) fullWidth > Date: Mon, 14 Mar 2022 21:15:44 +0000 Subject: [PATCH 03/47] More --- .../account_management/user_profile/form_label.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx index 2fca9bb59c9c8..3bc8f3f16a3dd 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx @@ -15,6 +15,16 @@ export interface FormLabelProps { isEqual: boolean; } +/** + * Renders a form label which indicates if a field value has changed. + * + * @example + * ```typescript + * + * Color + * + * ``` + */ export const FormLabel: FunctionComponent = ({ isEqual, children }) => { const { register } = useFormChangesContext(); From 87ae3f1c5b8a83f6639b9683373279d1f881128b Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 15 Mar 2022 11:30:05 +0000 Subject: [PATCH 04/47] Fix nav control for anonymous users --- x-pack/plugins/security/common/index.ts | 1 + x-pack/plugins/security/common/model/index.ts | 8 +++++++- x-pack/plugins/security/common/model/user_profile.ts | 11 +++++++++++ .../user_profile/user_profile_api_client.ts | 6 ++++-- .../public/nav_control/nav_control_component.tsx | 4 +++- .../security/server/routes/user_profile/get.ts | 7 ++++++- .../server/user_profile/user_profile_service.ts | 6 +++--- 7 files changed, 35 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index 9f8e7ffb04db5..befc5293e39d5 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -8,6 +8,7 @@ export type { SecurityLicense, SecurityLicenseFeatures, LoginLayout } from './licensing'; export type { AuthenticatedUser, + AuthenticatedUserProfile, AuthenticationProvider, PrivilegeDeprecationsService, PrivilegeDeprecationsRolesByFeatureIdRequest, diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 6f64f353e7728..84aba02265774 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -7,7 +7,13 @@ export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; export type { User, EditUser } from './user'; -export type { UserProfile, UserData, UserInfo, UserAvatar } from './user_profile'; +export type { + AuthenticatedUserProfile, + UserProfile, + UserData, + UserInfo, + UserAvatar, +} from './user_profile'; export { getUserAvatarColor, getUserAvatarInitials } from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index c23db10ac2f0a..74d282c667cec 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -7,6 +7,7 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; +import type { AuthenticationProvider } from '../'; import type { User } from './user'; import { getUserDisplayName } from './user'; @@ -47,6 +48,16 @@ export interface UserProfile { data: T; } +/** + * User profile enriched with session information. + */ +export interface AuthenticatedUserProfile extends UserProfile { + /** + * Authentication provider of this user's session. + */ + authentication_provider: AuthenticationProvider; +} + const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" const USER_AVATAR_MAX_INITIALS = 2; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts index 588b20f0a3042..60a93e8bc606f 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -7,7 +7,7 @@ import type { HttpStart } from 'src/core/public'; -import type { UserData, UserProfile } from '../../../common/model'; +import type { AuthenticatedUserProfile, UserData } from '../../../common/model'; const USER_PROFILE_URL = '/internal/security/user_profile'; @@ -19,7 +19,9 @@ export class UserProfileAPIClient { * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. */ public get(dataPath?: string) { - return this.http.get>(USER_PROFILE_URL, { query: { data: dataPath } }); + return this.http.get>(USER_PROFILE_URL, { + query: { data: dataPath }, + }); } /** diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 3978c6d5bd3e0..57b688507464e 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -77,7 +77,9 @@ export const SecurityNavControl: FunctionComponent = ({ ); - const isAnonymousUser = false; // authenticatedUser?.authentication_provider.type === 'anonymous'; + const isAnonymousUser = userProfile.value + ? userProfile.value.authentication_provider.type === 'anonymous' + : false; const items: EuiContextMenuPanelItemDescriptor[] = []; if (userMenuLinks.length) { diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 232f0161a1df4..aa38e08906f33 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -8,6 +8,7 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '../'; +import type { AuthenticatedUserProfile, UserData } from '../../../common/'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; @@ -36,7 +37,11 @@ export function defineGetUserProfileRoute({ const userProfileService = getUserProfileService(); try { const profile = await userProfileService.get(session.userProfileId, request.query.data); - return response.ok({ body: { ...profile, provider: session.provider } }); + const body: AuthenticatedUserProfile = { + ...profile, + authentication_provider: session.provider, + }; + return response.ok({ body }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); } diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index c0c5850e0c8d6..33c09d9f31cb8 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -7,7 +7,7 @@ import type { IClusterClient, KibanaRequest, Logger } from 'src/core/server'; -import type { UserData, UserInfo, UserProfile } from '../../common'; +import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; import { getDetailedErrorMessage } from '../errors'; import type { UserProfileGrant } from './user_profile_grant'; @@ -47,6 +47,7 @@ type GetProfileResponse = Record< access: {}; enabled: boolean; last_synchronized: number; + authentication_provider: AuthenticationProvider; } >; @@ -90,8 +91,7 @@ export class UserProfileService { dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' }`, }); - const { user, enabled, data } = body[uid]; - return { uid, enabled, user, data: data[KIBANA_DATA_ROOT] ?? {} }; + return { ...body[uid], data: body[uid].data[KIBANA_DATA_ROOT] ?? {} }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); throw error; From e696ce0a22b882ea9fa1cdb7b6215140eb62be3e Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 15 Mar 2022 11:30:49 +0000 Subject: [PATCH 05/47] Move presentational logic outside form row --- .../security/common/model/authenticated_user.ts | 10 ++++++++-- x-pack/plugins/security/common/model/index.ts | 2 +- .../account_management/user_profile/form_row.tsx | 16 +++++++--------- .../user_profile/user_profile.tsx | 4 +++- .../public/nav_control/nav_control_component.tsx | 15 ++++++--------- 5 files changed, 25 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d9fabc25df5ed..0ccd0abf9fc48 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -42,9 +42,15 @@ export interface AuthenticatedUser extends User { authentication_type: string; } -export function canUserChangePassword(user: AuthenticatedUser) { +export function isUserAnonymous(user: Pick) { + return user.authentication_provider.type === 'anonymous'; +} + +export function canUserChangePassword( + user: Pick +) { return ( REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) && - user.authentication_provider.type !== 'anonymous' + !isUserAnonymous(user) ); } diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 84aba02265774..c433d8e608267 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -17,7 +17,7 @@ export type { export { getUserAvatarColor, getUserAvatarInitials } from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; -export { canUserChangePassword } from './authenticated_user'; +export { canUserChangePassword, isUserAnonymous } from './authenticated_user'; export type { AuthenticationProvider } from './authentication_provider'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx index 34d23720238c4..a3376dd03d51e 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx @@ -15,17 +15,21 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FormLabel } from './form_label'; +export interface FormRowProps { + name?: string; +} + /** - * Renders a form row with correct error states, change indicator and optional indicator. + * Renders a form row with correct error states. * - * @example Renders as optional since form field has no validation rule. + * @example * ```typescript * * * * ``` */ -export const FormRow: FunctionComponent = (props) => { +export const FormRow: FunctionComponent = (props) => { const formik = useFormikContext(); const child = Children.only(props.children); const name = props.name ?? child.props.name; @@ -38,9 +42,6 @@ export const FormRow: FunctionComponent = ( const meta = formik.getFieldMeta(name); - const isOptional = !child.props.validate || !child.props.validate.required; - const isDisabled = props.isDisabled || child.props.disabled; - return ( = ( {props.label} ) : undefined } - labelAppend={ - !props.labelAppend && isOptional && !isDisabled ? : props.labelAppend - } > {child} diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 4a52d0555dec7..efd4d4a7b7780 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -47,7 +47,7 @@ import { isUserReserved } from '../../management/users/user_utils'; import { FormChangesProvider, useFormChanges, useFormChangesContext } from './form_changes'; import { FormField } from './form_field'; import { FormLabel } from './form_label'; -import { FormRow } from './form_row'; +import { FormRow, OptionalText } from './form_row'; import { UserAvatar } from './user_avatar'; import { UserProfileAPIClient } from './user_profile_api_client'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; @@ -281,6 +281,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Full name" /> } + labelAppend={} isDisabled={isReservedUser} fullWidth > @@ -294,6 +295,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Email address" /> } + labelAppend={} isDisabled={isReservedUser} fullWidth > diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 57b688507464e..9ad96a3e13885 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -23,7 +23,7 @@ import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { getUserDisplayName } from '../../common/model'; +import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import { UserAvatar } from '../account_management/user_profile'; import { useUserProfile } from '../components/use_current_user'; @@ -77,9 +77,7 @@ export const SecurityNavControl: FunctionComponent = ({ ); - const isAnonymousUser = userProfile.value - ? userProfile.value.authentication_provider.type === 'anonymous' - : false; + const isAnonymous = userProfile.value ? isUserAnonymous(userProfile.value) : false; const items: EuiContextMenuPanelItemDescriptor[] = []; if (userMenuLinks.length) { @@ -94,7 +92,7 @@ export const SecurityNavControl: FunctionComponent = ({ items.push(...userMenuLinkMenuItems); } - if (!isAnonymousUser) { + if (!isAnonymous) { const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); const profileMenuItem = { name: ( @@ -117,8 +115,8 @@ export const SecurityNavControl: FunctionComponent = ({ } } - const logoutMenuItem = { - name: isAnonymousUser ? ( + items.push({ + name: isAnonymous ? ( = ({ icon: , href: logoutUrl, 'data-test-subj': 'logoutLink', - }; - items.push(logoutMenuItem); + }); const panels = [ { From 10f8f9b0eb004abc4836ad7317f5dc85e4bf00b8 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 15 Mar 2022 21:39:08 +0000 Subject: [PATCH 06/47] Fix disabled state and update profile api --- .../public/account_management/user_profile/form_field.tsx | 2 +- .../public/account_management/user_profile/form_label.tsx | 2 +- .../account_management/user_profile/user_profile.tsx | 7 +++++-- .../security/server/user_profile/user_profile_service.ts | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx index 3faf4ace6c304..e2e7dc431a1e5 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx @@ -17,7 +17,7 @@ export interface FormFieldProps { } /** - * Renders a field inside with correct inline validation states. + * Renders a form field with correct inline validation states. * * @example Text field with validation rule: * ```typescript diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx index 3bc8f3f16a3dd..f8120666295a0 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx @@ -16,7 +16,7 @@ export interface FormLabelProps { } /** - * Renders a form label which indicates if a field value has changed. + * Renders a form label that indicates if a field value has changed. * * @example * ```typescript diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index efd4d4a7b7780..28db8d66447f5 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -369,7 +369,10 @@ export const UserProfile: FunctionComponent = ({ user, data }) - + = ({ user, data }) size="xl" /> - + {formik.values.avatarType === 'image' ? ( Date: Wed, 16 Mar 2022 12:51:11 +0000 Subject: [PATCH 07/47] remove presentational logic from form row --- .../user_profile/form_changes.tsx | 52 +++++++++++++---- .../user_profile/form_field.tsx | 4 +- .../user_profile/form_label.tsx | 24 +++++--- .../user_profile/form_row.tsx | 24 ++++---- .../user_profile/user_profile.tsx | 57 +++++++++++-------- 5 files changed, 101 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx index b0ddfa6f7ed5a..a1aaa27fee423 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_changes.tsx @@ -5,19 +5,38 @@ * 2.0. */ -import { createContext, useContext, useState } from 'react'; +import type { FunctionComponent } from 'react'; +import React, { createContext, useContext, useState } from 'react'; -export interface FormChanges { +export interface FormChangesProps { + /** + * Number of fields rendered on the page that have changed. + */ count: number; - register(isEqual: boolean): undefined | (() => void); + + /** + * Callback function used by a form field to indicate whether its current value is different to its initial value. + * + * @example + * ``` + * const { report } = useFormChangesContext(); + * const isEqual = formik.values.email === formik.initialValues.email; + * + * useEffect(() => report(isEqual), [isEqual]); + * ``` + */ + report(isEqual: boolean): undefined | (() => void); } -export const useFormChanges = (): FormChanges => { +/** + * Custom React hook that allows tracking changes within a form. + */ +export const useFormChanges = (): FormChangesProps => { const [count, setCount] = useState(0); return { count, - register: (isEqual: boolean) => { + report: (isEqual: boolean) => { if (!isEqual) { setCount((c) => c + 1); return () => setCount((c) => c - 1); @@ -26,18 +45,31 @@ export const useFormChanges = (): FormChanges => { }; }; -export const FormChangesContext = createContext(undefined); +const FormChangesContext = createContext(undefined); export const FormChangesProvider = FormChangesContext.Provider; +/** + * Custom React hook that returns all @see FormChangesProps state from context. + * + * @throws Error when called within a component that isn't a child of a component. + */ export function useFormChangesContext() { - const context = useContext(FormChangesContext); + const value = useContext(FormChangesContext); - if (!context) { + if (!value) { throw new Error( - 'FormChanges context is undefined, please verify you are calling useFormChangesContext() as child of a component.' + 'FormChanges context is undefined, please verify you are calling useFormChangesContext() as child of a component.' ); } - return context; + return value; } + +/** + * Component that allows tracking changes within a form. + */ +export const FormChanges: FunctionComponent = ({ children }) => { + const value = useFormChanges(); + return {children}; +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx index e2e7dc431a1e5..01387a0275fd1 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx @@ -17,7 +17,7 @@ export interface FormFieldProps { } /** - * Renders a form field with correct inline validation states. + * Polymorphic component that renders a form field with all state required for inline validation. * * @example Text field with validation rule: * ```typescript @@ -54,7 +54,7 @@ export function FormField({ {...rest} onBlur={(event) => { onBlur?.(event); - helpers.setTouched(true); // Marking as touched manually here since some EUI fields don't pass on the correct `event` argument causing errors when `field.onBlur(event)` is called as a result of spreading `field` props. + helpers.setTouched(true); // Marking as touched manually here since some EUI fields don't pass on the correct `event` argument causing errors when `field.onBlur(event)` is called as a result of spreading `field` props above. }} /> ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx index f8120666295a0..80722bd6a3cdf 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx @@ -6,33 +6,39 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { useFormikContext } from 'formik'; import type { FunctionComponent } from 'react'; import React, { useEffect } from 'react'; import { useFormChangesContext } from './form_changes'; export interface FormLabelProps { - isEqual: boolean; + /** + * Name of target form field. + */ + for: string; } /** - * Renders a form label that indicates if a field value has changed. + * Component that indicates whether a form field has changed. * * @example * ```typescript - * - * Color - * + * Color * ``` */ -export const FormLabel: FunctionComponent = ({ isEqual, children }) => { - const { register } = useFormChangesContext(); +export const FormLabel: FunctionComponent = (props) => { + const formik = useFormikContext(); + const { report } = useFormChangesContext(); - useEffect(() => register(isEqual), [isEqual]); // eslint-disable-line react-hooks/exhaustive-deps + const meta = formik.getFieldMeta(props.for); + const isEqual = meta.value === meta.initialValue; + + useEffect(() => report(isEqual), [isEqual]); // eslint-disable-line react-hooks/exhaustive-deps return ( - {children} + {props.children} {!isEqual ? ( diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx index a3376dd03d51e..ca479cb6bf6f9 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx @@ -13,14 +13,17 @@ import React, { Children } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { FormLabel } from './form_label'; - export interface FormRowProps { + /** + * Optional name of form field. + * + * If not provided the name will be inferred from its child element. + */ name?: string; } /** - * Renders a form row with correct error states. + * Renders a form row with correct state for inline validation. * * @example * ```typescript @@ -28,6 +31,8 @@ export interface FormRowProps { * * * ``` + * + * @throws Error if name hasn't been provided or can't be inferred. */ export const FormRow: FunctionComponent = (props) => { const formik = useFormikContext(); @@ -36,23 +41,14 @@ export const FormRow: FunctionComponent = (props if (!name) { throw new Error( - 'name prop is undefined, please verify you are rendering either itself or its direct child with a name prop to correctly identify the field.' + 'name prop is undefined, please verify you are either rendering itself or its child with a name prop.' ); } const meta = formik.getFieldMeta(name); return ( - {props.label} - ) : undefined - } - > + {child} ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 28db8d66447f5..7eaa8ed3f95e9 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -276,10 +276,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) + + + } labelAppend={} isDisabled={isReservedUser} @@ -290,10 +292,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) + + + } labelAppend={} isDisabled={isReservedUser} @@ -323,9 +327,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) + = ({ user, data }) {formik.values.avatarType === 'image' ? ( + + + } isDisabled={isReservedUser} fullWidth @@ -410,7 +414,6 @@ export const UserProfile: FunctionComponent = ({ user, data }) as={EuiFilePicker} name="data.avatar.imageUrl" value={undefined} /* EuiFilePicker breaks if value is provided */ - display="default" initialPromptText={ = ({ user, data }) ), }} accept={IMAGE_FILE_TYPES.join(',')} + display="default" fullWidth /> ) : ( <> + + + } isDisabled={isReservedUser} fullWidth @@ -470,10 +475,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) + + + } labelAppend={ !isReservedUser ? ( From 6147fb5f7d5eed7ed236cdafc983f71c8fcdad3d Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 16 Mar 2022 14:10:28 +0000 Subject: [PATCH 08/47] wip --- .../account_management/account_management_page.tsx | 7 ++++++- .../user_profile/user_profile.tsx | 14 +++++++++----- .../user_profile => components}/form_changes.tsx | 5 +++++ .../user_profile => components}/form_field.tsx | 12 ++++++------ .../user_profile => components}/form_label.tsx | 7 ++++--- .../user_profile => components}/form_row.tsx | 9 +++------ 6 files changed, 33 insertions(+), 21 deletions(-) rename x-pack/plugins/security/public/{account_management/user_profile => components}/form_changes.tsx (95%) rename x-pack/plugins/security/public/{account_management/user_profile => components}/form_field.tsx (83%) rename x-pack/plugins/security/public/{account_management/user_profile => components}/form_label.tsx (85%) rename x-pack/plugins/security/public/{account_management/user_profile => components}/form_row.tsx (84%) diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 604f01b04d249..dc52296c33c8a 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -8,6 +8,9 @@ import type { FunctionComponent } from 'react'; import React from 'react'; +import type { CoreStart } from 'src/core/public'; + +import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { getUserDisplayName } from '../../common/model'; import { Breadcrumb } from '../components/breadcrumb'; import { useCurrentUser, useUserProfile } from '../components/use_current_user'; @@ -15,6 +18,8 @@ import type { UserProfileProps } from './user_profile'; import { UserProfile } from './user_profile'; export const AccountManagementPage: FunctionComponent = () => { + const { services } = useKibana(); + const currentUser = useCurrentUser(); const userProfile = useUserProfile>('avatar'); @@ -25,7 +30,7 @@ export const AccountManagementPage: FunctionComponent = () => { const displayName = getUserDisplayName(userProfile.value.user); return ( - + ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 7eaa8ed3f95e9..3c39bcd834616 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -41,13 +41,17 @@ import { getUserAvatarInitials, } from '../../../common/model'; import { Breadcrumb } from '../../components/breadcrumb'; +import { + FormChangesProvider, + useFormChanges, + useFormChangesContext, +} from '../../components/form_changes'; +import { FormField } from '../../components/form_field'; +import { FormLabel } from '../../components/form_label'; +import { FormRow, OptionalText } from '../../components/form_row'; import { UserAPIClient } from '../../management/users'; import { ChangePasswordFlyout } from '../../management/users/edit_user/change_password_flyout'; import { isUserReserved } from '../../management/users/user_utils'; -import { FormChangesProvider, useFormChanges, useFormChangesContext } from './form_changes'; -import { FormField } from './form_field'; -import { FormLabel } from './form_label'; -import { FormRow, OptionalText } from './form_row'; import { UserAvatar } from './user_avatar'; import { UserProfileAPIClient } from './user_profile_api_client'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; @@ -175,7 +179,6 @@ export const UserProfile: FunctionComponent = ({ user, data }) text={i18n.translate('xpack.security.accountManagement.userProfile.title', { defaultMessage: 'Profile', })} - href="/security/account" > {showDeleteFlyout ? ( = ({ user, data }) + { const [count, setCount] = useState(0); diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx b/x-pack/plugins/security/public/components/form_field.tsx similarity index 83% rename from x-pack/plugins/security/public/account_management/user_profile/form_field.tsx rename to x-pack/plugins/security/public/components/form_field.tsx index 01387a0275fd1..c80482ccb6acb 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_field.tsx +++ b/x-pack/plugins/security/public/components/form_field.tsx @@ -19,12 +19,12 @@ export interface FormFieldProps { /** * Polymorphic component that renders a form field with all state required for inline validation. * - * @example Text field with validation rule: + * @example Render a text field with validation rule: * ```typescript * * ``` * - * @example Color picker which uses non-standard value prop and change handler: + * @example Render a color picker using non-standard value prop and change handler: * ```typescript * options.maxLength.value) { + if (options.maxLength && typeof value === 'object' && value.length > options.maxLength.value) { return options.maxLength.message; } - if (options.min && value.length < options.min.value) { + if (options.min && typeof value === 'number' && value < options.min.value) { return options.min.message; } - if (options.max && value.length > options.max.value) { + if (options.max && typeof value === 'number' && value > options.max.value) { return options.max.message; } if (options.pattern && !options.pattern.value.test(value)) { diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx b/x-pack/plugins/security/public/components/form_label.tsx similarity index 85% rename from x-pack/plugins/security/public/account_management/user_profile/form_label.tsx rename to x-pack/plugins/security/public/components/form_label.tsx index 80722bd6a3cdf..742f0bd0b79c0 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_label.tsx +++ b/x-pack/plugins/security/public/components/form_label.tsx @@ -20,11 +20,12 @@ export interface FormLabelProps { } /** - * Component that indicates whether a form field has changed. + * Component that visually indicates whether a field value has changed. * - * @example + * @example Renders a dot next to "Email" label when field value changes. * ```typescript - * Color + * Email + * * ``` */ export const FormLabel: FunctionComponent = (props) => { diff --git a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx b/x-pack/plugins/security/public/components/form_row.tsx similarity index 84% rename from x-pack/plugins/security/public/account_management/user_profile/form_row.tsx rename to x-pack/plugins/security/public/components/form_row.tsx index ca479cb6bf6f9..91183d078c296 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/form_row.tsx +++ b/x-pack/plugins/security/public/components/form_row.tsx @@ -23,7 +23,7 @@ export interface FormRowProps { } /** - * Renders a form row with correct state for inline validation. + * Component that renders a form row with all error states for inline validation. * * @example * ```typescript @@ -32,7 +32,7 @@ export interface FormRowProps { * * ``` * - * @throws Error if name hasn't been provided or can't be inferred. + * @throws Error if name hasn't been provided and can't be inferred. */ export const FormRow: FunctionComponent = (props) => { const formik = useFormikContext(); @@ -57,10 +57,7 @@ export const FormRow: FunctionComponent = (props export const OptionalText: FunctionComponent = () => { return ( - + ); }; From f46f750a838804f8900624fd134dee1f8fd84d01 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 16 Mar 2022 14:46:41 +0000 Subject: [PATCH 09/47] Fix initials --- .../user_profile/user_profile.tsx | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 3c39bcd834616..84b6c31a19578 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -26,7 +26,7 @@ import { } from '@elastic/eui'; import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; import type { FunctionComponent } from 'react'; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; @@ -76,8 +76,6 @@ export interface UserProfileFormValues { }; }; avatarType: 'initials' | 'image'; - customAvatarInitials: boolean; - customAvatarColor: boolean; } export const UserProfile: FunctionComponent = ({ user, data }) => { @@ -91,13 +89,10 @@ export const UserProfile: FunctionComponent = ({ user, data }) avatar: { initials: data.avatar?.initials || getUserAvatarInitials(user), color: data.avatar?.color || getUserAvatarColor(user), - imageUrl: data.avatar?.imageUrl ?? '', + imageUrl: data.avatar?.imageUrl || '', }, }, avatarType: data.avatar?.imageUrl ? 'image' : 'initials', - customAvatarInitials: - !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user), - customAvatarColor: !!data.avatar?.color && data.avatar?.color !== getUserAvatarColor(user), }); const formik = useFormik({ @@ -137,36 +132,28 @@ export const UserProfile: FunctionComponent = ({ user, data }) const formChanges = useFormChanges(); - useUpdateEffect(() => { - if (!formik.values.customAvatarInitials) { - formik.setFieldValue( - 'data.avatar.initials', - getUserAvatarInitials({ username: user.username, full_name: formik.values.user.full_name }) - ); - } + const customAvatarInitials = useRef( + !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) + ); - if (!formik.values.customAvatarColor) { - formik.setFieldValue( - 'data.avatar.color', - getUserAvatarColor({ username: user.username, full_name: formik.values.user.full_name }) - ); + useUpdateEffect(() => { + if (!customAvatarInitials.current) { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + formik.setFieldValue('data.avatar.initials', defaultInitials); } }, [formik.values.user.full_name]); useUpdateEffect(() => { - formik.setFieldValue( - 'customAvatarInitials', - formik.values.data.avatar.initials !== getUserAvatarInitials(user) - ); + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; }, [formik.values.data.avatar.initials]); - useUpdateEffect(() => { - formik.setFieldValue( - 'customAvatarColor', - formik.values.data.avatar.color !== getUserAvatarColor(user) - ); - }, [formik.values.data.avatar.color]); - const [showDeleteFlyout, setShowDeleteFlyout] = useState(false); const isReservedUser = isUserReserved(user); From 41645044ffa6eec1a30e4acf386680987e128da7 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 16 Mar 2022 16:33:24 +0000 Subject: [PATCH 10/47] . --- .../security/common/model/user_profile.ts | 3 +-- .../user_profile/user_profile_api_client.ts | 4 ++-- .../account_management/user_profile/utils.ts | 2 +- .../server/routes/user_profile/get.ts | 10 ++++++--- .../user_profile/user_profile_service.ts | 22 ++++++++++++------- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index 74d282c667cec..443b60ad74b14 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -7,8 +7,7 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; -import type { AuthenticationProvider } from '../'; -import type { User } from './user'; +import type { AuthenticationProvider, User } from '../'; import { getUserDisplayName } from './user'; export interface UserInfo extends User { diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts index 60a93e8bc606f..ab936b639df7c 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -7,7 +7,7 @@ import type { HttpStart } from 'src/core/public'; -import type { AuthenticatedUserProfile, UserData } from '../../../common/model'; +import type { AuthenticatedUserProfile, UserData } from '../../../common'; const USER_PROFILE_URL = '/internal/security/user_profile'; @@ -25,7 +25,7 @@ export class UserProfileAPIClient { } /** - * Updates user preferences of the current user. + * Updates user profile data of the current user. * @param data Application data to be written (merged with existing data). */ public update(data: T) { diff --git a/x-pack/plugins/security/public/account_management/user_profile/utils.ts b/x-pack/plugins/security/public/account_management/user_profile/utils.ts index 639c898273931..f63cf6bf06f0f 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/utils.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/utils.ts @@ -66,7 +66,7 @@ export function createImageHandler(callback: (imageUrl: string | undefined) => v * Returns the hex representation of a random color (e.g `#F1B7E2`) */ export function getRandomColor() { - return '#' + String(Math.floor(Math.random() * 16777215).toString(16)).padStart(6, '0'); + return '#' + String(Math.floor(Math.random() * 0xffffff).toString(16)).padStart(6, '0'); } export const VALID_HEX_COLOR = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i; diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index aa38e08906f33..de200c10e55b4 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -8,7 +8,7 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '../'; -import type { AuthenticatedUserProfile, UserData } from '../../../common/'; +import type { AuthenticatedUserProfile } from '../../../common/'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; @@ -36,8 +36,12 @@ export function defineGetUserProfileRoute({ const userProfileService = getUserProfileService(); try { - const profile = await userProfileService.get(session.userProfileId, request.query.data); - const body: AuthenticatedUserProfile = { + const profile = await userProfileService.get( + request, + session.userProfileId, + request.query.data + ); + const body: AuthenticatedUserProfile = { ...profile, authentication_provider: session.provider, }; diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 7dee40cb23cc4..c68d8fda978c6 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -25,7 +25,11 @@ export interface UserProfileServiceStart { * @param uid User ID * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. */ - get(uid: string, dataPath?: string): Promise>; + get( + request: KibanaRequest, + uid: string, + dataPath?: string + ): Promise>; /** * Updates user preferences by identifier. @@ -83,14 +87,16 @@ export class UserProfileService { } } - async function get(uid: string, dataPath?: string) { + async function get(request: KibanaRequest, uid: string, dataPath?: string) { try { - const body = await clusterClient.asInternalUser.transport.request>({ - method: 'GET', - path: `_security/profile/${uid}${ - dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' - }`, - }); + const body = await clusterClient + .asScoped(request) + .asCurrentUser.transport.request>({ + method: 'GET', + path: `_security/profile/${uid}${ + dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' + }`, + }); return { ...body[uid], data: body[uid].data[KIBANA_DATA_ROOT] ?? {} }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); From d96f416482c790f5ff969d2cf9b63eebdc123de6 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 16 Mar 2022 18:06:38 +0000 Subject: [PATCH 11/47] empty user avatar --- .../user_profile/user_avatar.tsx | 23 ++- .../user_profile/user_profile.tsx | 190 +++++++++--------- .../account_management/user_profile/utils.ts | 28 +-- 3 files changed, 132 insertions(+), 109 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx index ad6a1b512066f..4ac9c9155ef2e 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -6,7 +6,7 @@ */ import type { EuiAvatarProps } from '@elastic/eui'; -import { EuiAvatar } from '@elastic/eui'; +import { EuiAvatar, useEuiTheme } from '@elastic/eui'; import type { FunctionComponent, HTMLAttributes } from 'react'; import React from 'react'; @@ -18,18 +18,31 @@ import { } from '../../../common/model'; export interface UserAvatarProps extends Omit, 'color'> { - user: Pick; + user?: Pick; avatar?: IUserAvatar; size?: EuiAvatarProps['size']; isDisabled?: EuiAvatarProps['isDisabled']; } export const UserAvatar: FunctionComponent = ({ user, avatar, ...rest }) => { + const { euiTheme } = useEuiTheme(); + + if (!user) { + return ( + + ); + } + const displayName = getUserDisplayName(user); - const color = getUserAvatarColor(user, avatar); if (avatar && avatar.imageUrl) { - return ; + return ; } return ( @@ -37,7 +50,7 @@ export const UserAvatar: FunctionComponent = ({ user, avatar, . name={displayName} initials={getUserAvatarInitials(user, avatar)} initialsLength={2} - color={color} + color={getUserAvatarColor(user, avatar)} {...rest} /> ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 84b6c31a19578..bfb5cee1c1775 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -183,26 +183,30 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> ), rightSideItems: [ - - } - > - setShowDeleteFlyout(true)} - iconType="lock" - isDisabled={!canChangePassword} - fill - > + canChangePassword ? ( + setShowDeleteFlyout(true)} iconType="lock" fill> - , + ) : ( + + } + > + + + + + ), ], }} bottomBar={formChanges.count > 0 ? : null} @@ -252,7 +256,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) helpText={ !isReservedUser ? ( ) : null @@ -268,7 +272,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) label={ @@ -284,7 +288,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) label={ @@ -302,90 +306,92 @@ export const UserProfile: FunctionComponent = ({ user, data }) title={

} description={ } > - - - - - ), - }} - > - - } - onChange={() => formik.setFieldValue('avatarType', 'initials')} - isSelected={formik.values.avatarType === 'initials'} - isDisabled={isReservedUser} - > - - - - } - onChange={() => formik.setFieldValue('avatarType', 'image')} - isSelected={formik.values.avatarType === 'image'} - isDisabled={isReservedUser} - > - - - - - + {!isReservedUser && ( + <> + + + + + ), + }} + > + + } + onChange={() => formik.setFieldValue('avatarType', 'initials')} + isSelected={formik.values.avatarType === 'initials'} + isDisabled={isReservedUser} + > + + + + } + onChange={() => formik.setFieldValue('avatarType', 'image')} + isSelected={formik.values.avatarType === 'image'} + isDisabled={isReservedUser} + > + + + + + + + )} - + {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( + + ) : ( + + )} {formik.values.avatarType === 'image' ? ( @@ -393,7 +399,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) label={ @@ -416,7 +422,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) })} validate={{ required: i18n.translate( - 'xpack.security.accountManagement.userProfile.initialsRequiredError', + 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', { defaultMessage: 'Upload an image.', } @@ -433,7 +439,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) label={ @@ -468,7 +474,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) label={ @@ -484,7 +490,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) style={{ height: 18 }} > @@ -507,7 +513,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) pattern: { value: VALID_HEX_COLOR, message: i18n.translate( - 'xpack.security.accountManagement.userProfile.colorInvalidError', + 'xpack.security.accountManagement.userProfile.colorPatternError', { defaultMessage: 'Enter a valid HEX color code.', } diff --git a/x-pack/plugins/security/public/account_management/user_profile/utils.ts b/x-pack/plugins/security/public/account_management/user_profile/utils.ts index f63cf6bf06f0f..fd15350288493 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/utils.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/utils.ts @@ -25,19 +25,23 @@ export function resizeImage(imageUrl: string, maxSize: number) { return resolve(imageUrl); } - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (context) { - if (image.width >= image.height) { - canvas.width = maxSize; - canvas.height = Math.floor((image.height * maxSize) / image.width); - } else { - canvas.height = maxSize; - canvas.width = Math.floor((image.width * maxSize) / image.height); + try { + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context) { + if (image.width >= image.height) { + canvas.width = maxSize; + canvas.height = Math.floor((image.height * maxSize) / image.width); + } else { + canvas.height = maxSize; + canvas.width = Math.floor((image.width * maxSize) / image.height); + } + context.drawImage(image, 0, 0, canvas.width, canvas.height); + const resizedDataUrl = canvas.toDataURL(); + return resolve(resizedDataUrl); } - context.drawImage(image, 0, 0, canvas.width, canvas.height); - const resizedDataUrl = canvas.toDataURL(); - return resolve(resizedDataUrl); + } catch (error) { + return reject(error); } return reject(); From 1a42bf42b85d83ac88f3eae1dd8617d72dac2c16 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 16 Mar 2022 21:39:26 +0000 Subject: [PATCH 12/47] simplify reserved user screen --- .../user_profile/user_avatar.tsx | 9 ++- .../user_profile/user_profile.tsx | 73 +++++++++---------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx index 4ac9c9155ef2e..61df0b9959c48 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -6,7 +6,7 @@ */ import type { EuiAvatarProps } from '@elastic/eui'; -import { EuiAvatar, useEuiTheme } from '@elastic/eui'; +import { EuiAvatar, shade, useEuiTheme } from '@elastic/eui'; import type { FunctionComponent, HTMLAttributes } from 'react'; import React from 'react'; @@ -25,15 +25,16 @@ export interface UserAvatarProps extends Omit, 'c } export const UserAvatar: FunctionComponent = ({ user, avatar, ...rest }) => { - const { euiTheme } = useEuiTheme(); + const { euiTheme, colorMode } = useEuiTheme(); if (!user) { + const color = colorMode === 'LIGHT' ? euiTheme.colors.lightShade : euiTheme.colors.mediumShade; return ( ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index bfb5cee1c1775..659fdaabf4c23 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -266,39 +266,41 @@ export const UserProfile: FunctionComponent = ({ user, data }) >
- ) : null} - - - - - } - labelAppend={} - isDisabled={isReservedUser} - fullWidth - > - - + ) : ( + <> + + + + } + labelAppend={} + isDisabled={isReservedUser} + fullWidth + > + + - - - - } - labelAppend={} - isDisabled={isReservedUser} - fullWidth - > - - + + + + } + labelAppend={} + isDisabled={isReservedUser} + fullWidth + > + + + + )} = ({ user, data }) )} - + {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( ) : ( @@ -393,7 +392,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> )} - + {formik.values.avatarType === 'image' ? ( Date: Thu, 17 Mar 2022 08:48:02 +0000 Subject: [PATCH 13/47] . --- x-pack/plugins/security/common/model/index.ts | 6 +++++- .../plugins/security/common/model/user_profile.ts | 15 ++++++++++++--- .../user_profile/user_avatar.tsx | 3 ++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index c433d8e608267..7aec9a3021d02 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -14,7 +14,11 @@ export type { UserInfo, UserAvatar, } from './user_profile'; -export { getUserAvatarColor, getUserAvatarInitials } from './user_profile'; +export { + getUserAvatarColor, + getUserAvatarInitials, + USER_AVATAR_MAX_INITIALS, +} from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; export { canUserChangePassword, isUserAnonymous } from './authenticated_user'; diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index 443b60ad74b14..e741ea56cf09e 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -10,20 +10,29 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; import type { AuthenticationProvider, User } from '../'; import { getUserDisplayName } from './user'; +/** + * User information returned in user profile. + */ export interface UserInfo extends User { active: boolean; } +/** + * Avatar stored in user profile. + */ export interface UserAvatar { initials?: string; color?: string; imageUrl?: string; } +/** + * Placeholder for data stored in user profile. + */ export type UserData = Record; /** - * Describes properties of the user's profile. + * Describes properties stored in user profile. */ export interface UserProfile { /** @@ -57,8 +66,8 @@ export interface AuthenticatedUserProfile extends authentication_provider: AuthenticationProvider; } -const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" -const USER_AVATAR_MAX_INITIALS = 2; +export const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" +export const USER_AVATAR_MAX_INITIALS = 2; /** * Determines the color for the provided user profile. diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx index 61df0b9959c48..b054ba4207592 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -15,6 +15,7 @@ import { getUserAvatarColor, getUserAvatarInitials, getUserDisplayName, + USER_AVATAR_MAX_INITIALS, } from '../../../common/model'; export interface UserAvatarProps extends Omit, 'color'> { @@ -50,7 +51,7 @@ export const UserAvatar: FunctionComponent = ({ user, avatar, . From 97fe03941bfc3d9197a689ee1ac3e2c9a5b016fa Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 18 Mar 2022 10:19:22 +0000 Subject: [PATCH 14/47] unit tests --- .../public/components/form_changes.test.tsx | 68 +++++++ .../public/components/form_changes.tsx | 9 +- .../public/components/form_field.test.tsx | 178 ++++++++++++++++++ .../security/public/components/form_field.tsx | 67 ++++--- .../public/components/form_label.test.tsx | 44 +++++ .../security/public/components/form_label.tsx | 12 +- .../public/components/form_row.test.tsx | 37 ++++ .../security/public/components/form_row.tsx | 11 +- 8 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 x-pack/plugins/security/public/components/form_changes.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_field.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_label.test.tsx create mode 100644 x-pack/plugins/security/public/components/form_row.test.tsx diff --git a/x-pack/plugins/security/public/components/form_changes.test.tsx b/x-pack/plugins/security/public/components/form_changes.test.tsx new file mode 100644 index 0000000000000..3223bb727ddfb --- /dev/null +++ b/x-pack/plugins/security/public/components/form_changes.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import type { RevertFunction } from './form_changes'; +import { useFormChanges } from './form_changes'; + +describe('useFormChanges', () => { + it('should return correct contract', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current).toEqual({ + count: 0, + report: expect.any(Function), + }); + }); + + it('should increase count when field changes', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + act(() => { + result.current.report(false); + }); + + expect(result.current.count).toEqual(1); + }); + + it('should decrease count when field changes back', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + let revert: RevertFunction | undefined; + act(() => { + revert = result.current.report(false); + }); + + expect(revert).not.toBeUndefined(); + expect(result.current.count).toEqual(1); + + act(() => { + revert!(); + }); + + expect(result.current.count).toEqual(0); + }); + + it('should not increase count when field remains unchanged', () => { + const { result } = renderHook(useFormChanges); + + expect(result.current.count).toEqual(0); + + let revert: RevertFunction | undefined; + act(() => { + revert = result.current.report(true); + }); + + expect(revert).toBeUndefined(); + expect(result.current.count).toEqual(0); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_changes.tsx b/x-pack/plugins/security/public/components/form_changes.tsx index aae382a22a80e..7d88c3af983ed 100644 --- a/x-pack/plugins/security/public/components/form_changes.tsx +++ b/x-pack/plugins/security/public/components/form_changes.tsx @@ -25,9 +25,12 @@ export interface FormChangesProps { * useEffect(() => report(isEqual), [isEqual]); * ``` */ - report(isEqual: boolean): undefined | (() => void); + report: ReportFunction; } +export type ReportFunction = (isEqual: boolean) => undefined | RevertFunction; +export type RevertFunction = () => void; + /** * Custom React hook that allows tracking changes within a form. * @@ -41,7 +44,7 @@ export const useFormChanges = (): FormChangesProps => { return { count, - report: (isEqual: boolean) => { + report: (isEqual) => { if (!isEqual) { setCount((c) => c + 1); return () => setCount((c) => c - 1); @@ -57,7 +60,7 @@ export const FormChangesProvider = FormChangesContext.Provider; /** * Custom React hook that returns all @see FormChangesProps state from context. * - * @throws Error when called within a component that isn't a child of a component. + * @throws Error if called within a component that isn't a child of a `` component. */ export function useFormChangesContext() { const value = useContext(FormChangesContext); diff --git a/x-pack/plugins/security/public/components/form_field.test.tsx b/x-pack/plugins/security/public/components/form_field.test.tsx new file mode 100644 index 0000000000000..0abb7c82a833e --- /dev/null +++ b/x-pack/plugins/security/public/components/form_field.test.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFieldNumber, EuiFieldText } from '@elastic/eui'; +import { mount } from 'enzyme'; +import { Formik } from 'formik'; +import React from 'react'; + +import { createFieldValidator, FormField } from './form_field'; + +const onSubmit = jest.fn(); + +describe('FormField', () => { + it('should render text field by default', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.exists(EuiFieldText)).toEqual(true); + }); + + it('should render custom component if specified', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.exists(EuiFieldNumber)).toEqual(true); + }); + + it('should render component with correct field props and event handlers', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(EuiFieldText).props()).toEqual( + expect.objectContaining({ + name: 'email', + value: 'mail@example.com', + onChange: expect.any(Function), + onBlur: expect.any(Function), + }) + ); + }); + + it('should mark as invalid if field has errors and has been touched', () => { + const assertions = [ + { error: 'Error', touched: true, isInvalid: true }, + { error: 'Error', touched: false, isInvalid: false }, + { error: undefined, touched: true, isInvalid: false }, + ]; + assertions.forEach(({ error, touched, isInvalid }) => { + const wrapper = mount( + + + + ); + expect(wrapper.find(EuiFieldText).props()).toEqual(expect.objectContaining({ isInvalid })); + }); + }); +}); + +describe('createFieldValidator', () => { + it('should validate required field', () => { + const validate = createFieldValidator({ + required: 'Error', + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate('')).toEqual('Error'); + + expect(validate(0)).toBeUndefined(); + expect(validate(1)).toBeUndefined(); + expect(validate('a')).toBeUndefined(); + expect(validate({})).toBeUndefined(); + expect(validate([])).toBeUndefined(); + }); + + it('should validate field pattern', () => { + const validate = createFieldValidator({ + pattern: { + value: /^[a-z]{2}$/, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate(0)).toEqual('Error'); + expect(validate(1)).toEqual('Error'); + expect(validate('a')).toEqual('Error'); + + expect(validate('ab')).toBeUndefined(); + }); + + it('should validate minimum length ', () => { + const validate = createFieldValidator({ + minLength: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate('a')).toEqual('Error'); + expect(validate([0])).toEqual('Error'); + + expect(validate('ab')).toBeUndefined(); + expect(validate([0, 1])).toBeUndefined(); + }); + + it('should validate maximum length', () => { + const validate = createFieldValidator({ + maxLength: { + value: 2, + message: 'Error', + }, + }); + + expect(validate('abc')).toEqual('Error'); + expect(validate([0, 1, 3])).toEqual('Error'); + + expect(validate(undefined)).toBeUndefined(); + expect(validate(null)).toBeUndefined(); + expect(validate('ab')).toBeUndefined(); + expect(validate([0, 1])).toBeUndefined(); + }); + + it('should validate minimum value', () => { + const validate = createFieldValidator({ + min: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(null)).toEqual('Error'); + expect(validate(1)).toEqual('Error'); + expect(validate('1')).toEqual('Error'); + + expect(validate(2)).toBeUndefined(); + expect(validate('2')).toBeUndefined(); + }); + + it('should validate maximum value', () => { + const validate = createFieldValidator({ + max: { + value: 2, + message: 'Error', + }, + }); + + expect(validate(undefined)).toEqual('Error'); + expect(validate(3)).toEqual('Error'); + expect(validate('3')).toEqual('Error'); + + expect(validate(null)).toBeUndefined(); + expect(validate(2)).toBeUndefined(); + expect(validate('2')).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_field.tsx b/x-pack/plugins/security/public/components/form_field.tsx index c80482ccb6acb..6e223f067c99b 100644 --- a/x-pack/plugins/security/public/components/form_field.tsx +++ b/x-pack/plugins/security/public/components/form_field.tsx @@ -8,9 +8,10 @@ import { EuiFieldText } from '@elastic/eui'; import { useField } from 'formik'; import type { FieldValidator } from 'formik'; +import type { ComponentPropsWithoutRef, ElementType } from 'react'; import React from 'react'; -export interface FormFieldProps { +export interface FormFieldProps { as?: T; name: string; validate?: FieldValidator | ValidateOptions; @@ -19,27 +20,33 @@ export interface FormFieldProps { /** * Polymorphic component that renders a form field with all state required for inline validation. * - * @example Render a text field with validation rule: + * @example Text field with validation rule: * ```typescript - * + * + * + * * ``` * - * @example Render a color picker using non-standard value prop and change handler: + * @example Color picker using non-standard value prop and change handler: * ```typescript - * formik.setFieldValue('color', value)} - * /> + * + * formik.setFieldValue('color', value)} + * /> + * * ``` + * + * @throws Error if not a child of a `` component. */ -export function FormField({ +export function FormField({ as, validate, onBlur, ...rest -}: FormFieldProps & Omit, keyof FormFieldProps>) { +}: FormFieldProps & Omit, keyof FormFieldProps>) { const Component = as || EuiFieldText; const [field, meta, helpers] = useField({ @@ -53,8 +60,8 @@ export function FormField({ {...field} {...rest} onBlur={(event) => { + helpers.setTouched(true); // Marking as touched manually here since some EUI components don't pass on the native blur event which is required by `field.onBlur()`. onBlur?.(event); - helpers.setTouched(true); // Marking as touched manually here since some EUI fields don't pass on the correct `event` argument causing errors when `field.onBlur(event)` is called as a result of spreading `field` props above. }} /> ); @@ -62,6 +69,10 @@ export function FormField({ export interface ValidateOptions { required?: string; + pattern?: { + value: RegExp; + message: string; + }; minLength?: { value: number; message: string; @@ -78,31 +89,37 @@ export interface ValidateOptions { value: number; message: string; }; - pattern?: { - value: RegExp; - message: string; - }; } export function createFieldValidator(options: ValidateOptions): FieldValidator { return (value: any) => { - if (options.required && !value) { + if (options.required && typeof value !== 'number' && !value) { return options.required; } - if (options.minLength && typeof value === 'object' && value.length < options.minLength.value) { + if (options.pattern && !options.pattern.value.test(value)) { + return options.pattern.message; + } + if ( + options.minLength && + (!value || + ((typeof value === 'object' || typeof value === 'string') && + value.length < options.minLength.value)) + ) { return options.minLength.message; } - if (options.maxLength && typeof value === 'object' && value.length > options.maxLength.value) { + if ( + options.maxLength && + value && + (typeof value === 'object' || typeof value === 'string') && + value.length > options.maxLength.value + ) { return options.maxLength.message; } - if (options.min && typeof value === 'number' && value < options.min.value) { + if (options.min && (isNaN(value) || value < options.min.value)) { return options.min.message; } - if (options.max && typeof value === 'number' && value > options.max.value) { + if (options.max && (isNaN(value) || value > options.max.value)) { return options.max.message; } - if (options.pattern && !options.pattern.value.test(value)) { - return options.pattern.message; - } }; } diff --git a/x-pack/plugins/security/public/components/form_label.test.tsx b/x-pack/plugins/security/public/components/form_label.test.tsx new file mode 100644 index 0000000000000..3f3ef72101e7a --- /dev/null +++ b/x-pack/plugins/security/public/components/form_label.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, render } from '@testing-library/react'; +import type { FormikContextType } from 'formik'; +import { Formik, FormikConsumer } from 'formik'; +import React from 'react'; + +import { FormChangesProvider } from './form_changes'; +import { FormLabel } from './form_label'; + +describe('FormLabel', () => { + it('should report form changes', () => { + const onSubmit = jest.fn(); + const report = jest.fn(); + + let formik: FormikContextType; + render( + + + + + {(value) => { + formik = value; + return null; + }} + + + + ); + + expect(report).toHaveBeenLastCalledWith(true); + + act(() => { + formik.setFieldValue('email', 'mail@example.com'); + }); + + expect(report).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_label.tsx b/x-pack/plugins/security/public/components/form_label.tsx index 742f0bd0b79c0..724dfa9902b02 100644 --- a/x-pack/plugins/security/public/components/form_label.tsx +++ b/x-pack/plugins/security/public/components/form_label.tsx @@ -24,9 +24,17 @@ export interface FormLabelProps { * * @example Renders a dot next to "Email" label when field value changes. * ```typescript - * Email - * + * + * + * Email}> + * + * + * + * * ``` + * + * @throws Error if not a child of a `` component. + * @throws Error if not a child of a `` component. */ export const FormLabel: FunctionComponent = (props) => { const formik = useFormikContext(); diff --git a/x-pack/plugins/security/public/components/form_row.test.tsx b/x-pack/plugins/security/public/components/form_row.test.tsx new file mode 100644 index 0000000000000..73a7a5425672e --- /dev/null +++ b/x-pack/plugins/security/public/components/form_row.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFormRow } from '@elastic/eui'; +import { mount } from 'enzyme'; +import { Formik } from 'formik'; +import React from 'react'; + +import { FormRow } from './form_row'; + +describe('FormRow', () => { + it('should render form row with correct error state', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(EuiFormRow).props()).toEqual( + expect.objectContaining({ + error: 'Error', + isInvalid: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/security/public/components/form_row.tsx b/x-pack/plugins/security/public/components/form_row.tsx index 91183d078c296..a6f4e475c3e19 100644 --- a/x-pack/plugins/security/public/components/form_row.tsx +++ b/x-pack/plugins/security/public/components/form_row.tsx @@ -27,12 +27,15 @@ export interface FormRowProps { * * @example * ```typescript - * - * - * + * + * + * + * + * * ``` * - * @throws Error if name hasn't been provided and can't be inferred. + * @throws Error if not a child of a `` component. + * @throws Error if `name` prop is not set and can't be inferred from its child element. */ export const FormRow: FunctionComponent = (props) => { const formik = useFormikContext(); From ea08f1680c10a8110e16958f968613e028dfedc5 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Fri, 18 Mar 2022 10:35:33 +0000 Subject: [PATCH 15/47] type fixes --- .../security/server/authentication/authenticator.test.ts | 2 +- x-pack/plugins/security/server/user_profile/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index aaf0d27558a41..8804b542e6f8d 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -27,6 +27,7 @@ import { } from '../../common/constants'; import { licenseMock } from '../../common/licensing/index.mock'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { userProfileMock } from '../../common/model/user_profile.mock'; import type { AuditLogger } from '../audit'; import { auditLoggerMock, auditServiceMock } from '../audit/mocks'; import { ConfigSchema, createConfig } from '../config'; @@ -35,7 +36,6 @@ import { securityMock } from '../mocks'; import type { SessionValue } from '../session_management'; import { sessionMock } from '../session_management/index.mock'; import type { UserProfileGrant } from '../user_profile'; -import { userProfileMock } from '../user_profile/user_profile.mock'; import { userProfileServiceMock } from '../user_profile/user_profile_service.mock'; import { AuthenticationResult } from './authentication_result'; import type { AuthenticatorOptions } from './authenticator'; diff --git a/x-pack/plugins/security/server/user_profile/index.ts b/x-pack/plugins/security/server/user_profile/index.ts index b9650bd5fb2ee..dd058107ffa60 100644 --- a/x-pack/plugins/security/server/user_profile/index.ts +++ b/x-pack/plugins/security/server/user_profile/index.ts @@ -10,5 +10,4 @@ export type { UserProfileServiceStart, UserProfileServiceStartParams, } from './user_profile_service'; -export type { UserProfile, UserData } from './user_profile'; export type { UserProfileGrant } from './user_profile_grant'; From d4163452135f0ad9bbfc79c8b2cce944ce625500 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 21 Mar 2022 11:04:15 +0000 Subject: [PATCH 16/47] . --- .../public/components/form_row.test.tsx | 47 +++++++++++-------- .../user_profile/user_profile_service.test.ts | 44 ++++++++++------- 2 files changed, 55 insertions(+), 36 deletions(-) diff --git a/x-pack/plugins/security/public/components/form_row.test.tsx b/x-pack/plugins/security/public/components/form_row.test.tsx index 73a7a5425672e..ad2a5818fca3e 100644 --- a/x-pack/plugins/security/public/components/form_row.test.tsx +++ b/x-pack/plugins/security/public/components/form_row.test.tsx @@ -13,25 +13,32 @@ import React from 'react'; import { FormRow } from './form_row'; describe('FormRow', () => { - it('should render form row with correct error state', () => { - const wrapper = mount( - - - - - - ); - - expect(wrapper.find(EuiFormRow).props()).toEqual( - expect.objectContaining({ - error: 'Error', - isInvalid: true, - }) - ); + it('should render form row with correct error states', () => { + const assertions = [ + { error: 'Error', touched: true, isInvalid: true }, + { error: 'Error', touched: false, isInvalid: false }, + { error: undefined, touched: true, isInvalid: false }, + ]; + assertions.forEach(({error, touched, isInvalid}) => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(EuiFormRow).props()).toEqual( + expect.objectContaining({ + error, + isInvalid, + }) + ); + }) }); }); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index e2bdac6940dd0..d44cb0ef23378 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -7,7 +7,7 @@ import { errors } from '@elastic/elasticsearch'; -import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, httpServerMock, loggingSystemMock } from 'src/core/server/mocks'; import { userProfileMock } from '../../common/model/user_profile.mock'; import { securityMock } from '../mocks'; @@ -15,6 +15,7 @@ import { UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); const userProfileService = new UserProfileService(logger); +const requestMock = httpServerMock.createKibanaRequest(); describe('UserProfileService', () => { let mockStartParams: { @@ -40,6 +41,9 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({ [userProfile.uid]: userProfile, }); + mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request.mockResolvedValue({ + [userProfile.uid]: userProfile, + }); }); afterEach(() => { @@ -60,7 +64,7 @@ describe('UserProfileService', () => { describe('#get', () => { it('should get user profile', async () => { const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` + await expect(startContract.get(requestMock, 'UID')).resolves.toMatchInlineSnapshot(` Object { "data": Object { "avatar": "fun.gif", @@ -75,24 +79,28 @@ describe('UserProfileService', () => { }, } `); - expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request + ).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID', }); }); it('should handle errors when get user profile fails', async () => { - mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( - new Error('Fail') - ); + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.transport.request.mockRejectedValue(new Error('Fail')); const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`); + await expect(startContract.get(requestMock, 'UID')).rejects.toMatchInlineSnapshot( + `[Error: Fail]` + ); expect(logger.error).toHaveBeenCalled(); }); it('should get user profile and application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` + await expect(startContract.get(requestMock, 'UID', '*')).resolves.toMatchInlineSnapshot(` Object { "data": Object { "avatar": "fun.gif", @@ -107,7 +115,9 @@ describe('UserProfileService', () => { }, } `); - expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request + ).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID?data=kibana.*', }); @@ -117,10 +127,12 @@ describe('UserProfileService', () => { describe('#update', () => { it('should update application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); - await startContract.update('UID', { + await startContract.update(requestMock, 'UID', { avatar: 'boring.png', }); - expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + expect( + mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request + ).toHaveBeenCalledWith({ body: { data: { kibana: { @@ -129,17 +141,17 @@ describe('UserProfileService', () => { }, }, method: 'POST', - path: '_security/profile/_data/UID', + path: '_security/profile/UID/_data', }); }); it('should handle errors when update user profile fails', async () => { - mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( - new Error('Fail') - ); + mockStartParams.clusterClient + .asScoped() + .asCurrentUser.transport.request.mockRejectedValue(new Error('Fail')); const startContract = userProfileService.start(mockStartParams); await expect( - startContract.update('UID', { + startContract.update(requestMock, 'UID', { avatar: 'boring.png', }) ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); From 55eab66a30c40b0e7cc61172990eb44fa9b4a765 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 21 Mar 2022 18:54:55 +0000 Subject: [PATCH 17/47] . --- .../account_management_app.test.ts | 81 ------------------- .../account_management_app.test.tsx | 77 ++++++++++++++++++ .../account_management_page.test.tsx | 27 ++++--- .../server/routes/user_profile/get.ts | 7 +- .../server/routes/user_profile/update.ts | 3 +- .../user_profile/user_profile_service.test.ts | 42 ++++------ .../user_profile/user_profile_service.ts | 30 +++---- 7 files changed, 122 insertions(+), 145 deletions(-) delete mode 100644 x-pack/plugins/security/public/account_management/account_management_app.test.ts create mode 100644 x-pack/plugins/security/public/account_management/account_management_app.test.tsx diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts deleted file mode 100644 index 11b188a1d1370..0000000000000 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ /dev/null @@ -1,81 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -jest.mock('./account_management_page'); - -import type { AppMount } from 'src/core/public'; -import { AppNavLinkStatus } from 'src/core/public'; -import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; - -import { UserAPIClient } from '../management'; -import { securityMock } from '../mocks'; -import { accountManagementApp } from './account_management_app'; - -describe('accountManagementApp', () => { - it('properly registers application', () => { - const coreSetupMock = coreMock.createSetup(); - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: securityMock.createSetup().authc, - }); - - expect(coreSetupMock.application.register).toHaveBeenCalledTimes(1); - - const [[appRegistration]] = coreSetupMock.application.register.mock.calls; - expect(appRegistration).toEqual({ - id: 'security_account', - appRoute: '/security/account', - navLinkStatus: AppNavLinkStatus.hidden, - title: 'Account Management', - mount: expect.any(Function), - }); - }); - - it('properly sets breadcrumbs and renders application', async () => { - const coreSetupMock = coreMock.createSetup(); - const coreStartMock = coreMock.createStart(); - coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]); - - const authcMock = securityMock.createSetup().authc; - - accountManagementApp.create({ - application: coreSetupMock.application, - getStartServices: coreSetupMock.getStartServices, - authc: authcMock, - }); - - const [[{ mount }]] = coreSetupMock.application.register.mock.calls; - const appMountParams = { - element: document.createElement('div'), - appBasePath: '', - onAppLeave: jest.fn(), - setHeaderActionMenu: jest.fn(), - history: scopedHistoryMock.create(), - theme$: themeServiceMock.createTheme$(), - }; - await (mount as AppMount)(appMountParams); - - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledWith([ - { text: 'Account Management' }, - ]); - - const mockRenderApp = jest.requireMock('./account_management_page').renderAccountManagementPage; - expect(mockRenderApp).toHaveBeenCalledTimes(1); - expect(mockRenderApp).toHaveBeenCalledWith( - coreStartMock.i18n, - { element: appMountParams.element, theme$: appMountParams.theme$ }, - { - userAPIClient: expect.any(UserAPIClient), - authc: authcMock, - notifications: coreStartMock.notifications, - } - ); - }); -}); diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx new file mode 100644 index 0000000000000..ff0c136a74b76 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from '@testing-library/react'; +import { noop } from 'lodash'; + +import type { AppUnmount } from 'src/core/public'; +import { AppNavLinkStatus } from 'src/core/public'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; + +import { securityMock } from '../mocks'; +import { accountManagementApp } from './account_management_app'; +import * as AccountManagementPageImports from './account_management_page'; + +const AccountManagementPageMock = jest + .spyOn(AccountManagementPageImports, 'AccountManagementPage') + .mockReturnValue(null); + +const element = document.body.appendChild(document.createElement('div')); + +describe('accountManagementApp', () => { + it('should register application', () => { + const { authc } = securityMock.createSetup(); + const { application, getStartServices } = coreMock.createSetup(); + + accountManagementApp.create({ + application, + getStartServices, + authc, + }); + + expect(application.register).toHaveBeenCalledTimes(1); + expect(application.register).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'security_account', + appRoute: '/security/account', + navLinkStatus: AppNavLinkStatus.hidden, + mount: expect.any(Function), + }) + ); + }); + + it('should render AccountManagementPage on mount', async () => { + const { authc } = securityMock.createSetup(); + const { application, getStartServices } = coreMock.createSetup(); + const coreStart = coreMock.createStart(); + getStartServices.mockResolvedValue([coreStart, {}, {}]); + + accountManagementApp.create({ + application, + authc, + getStartServices, + }); + + const [[{ mount }]] = application.register.mock.calls; + + let unmount: AppUnmount = noop; + await act(async () => { + unmount = await mount({ + element, + appBasePath: '', + onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), + history: scopedHistoryMock.create(), + theme$: themeServiceMock.createTheme$(), + }); + }); + + expect(AccountManagementPageMock).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index fb73322655399..8602ff3d5388d 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -6,15 +6,17 @@ */ import { act } from '@testing-library/react'; +import { mount } from 'enzyme'; import React from 'react'; -import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; -import { coreMock } from 'src/core/public/mocks'; +import { nextTick } from '@kbn/test-jest-helpers'; +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; import type { AuthenticatedUser } from '../../common/model'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { userAPIClientMock } from '../management/users/index.mock'; import { securityMock } from '../mocks'; +import { Providers } from './account_management_app'; import { AccountManagementPage } from './account_management_page'; interface Options { @@ -48,12 +50,15 @@ function getSecuritySetupMock({ currentUser }: { currentUser: AuthenticatedUser describe('', () => { it(`displays users full name, username, and email address`, async () => { const user = createUser(); - const wrapper = mountWithIntl( - + > + + ); await act(async () => { @@ -70,7 +75,7 @@ describe('', () => { it(`displays username when full_name is not provided`, async () => { const user = createUser({ withFullName: false }); - const wrapper = mountWithIntl( + const wrapper = mount( ', () => { it(`displays a placeholder when no email address is provided`, async () => { const user = createUser({ withEmail: false }); - const wrapper = mountWithIntl( + const wrapper = mount( ', () => { it(`displays change password form for users in the native realm`, async () => { const user = createUser(); - const wrapper = mountWithIntl( + const wrapper = mount( ', () => { it(`does not display change password form for users in the saml realm`, async () => { const user = createUser({ realm: 'saml' }); - const wrapper = mountWithIntl( + const wrapper = mount( { let mockStartParams: { @@ -41,9 +40,6 @@ describe('UserProfileService', () => { mockStartParams.clusterClient.asInternalUser.transport.request.mockResolvedValue({ [userProfile.uid]: userProfile, }); - mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request.mockResolvedValue({ - [userProfile.uid]: userProfile, - }); }); afterEach(() => { @@ -64,7 +60,7 @@ describe('UserProfileService', () => { describe('#get', () => { it('should get user profile', async () => { const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get(requestMock, 'UID')).resolves.toMatchInlineSnapshot(` + await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` Object { "data": Object { "avatar": "fun.gif", @@ -79,28 +75,24 @@ describe('UserProfileService', () => { }, } `); - expect( - mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request - ).toHaveBeenCalledWith({ + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID', }); }); it('should handle errors when get user profile fails', async () => { - mockStartParams.clusterClient - .asScoped() - .asCurrentUser.transport.request.mockRejectedValue(new Error('Fail')); - const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get(requestMock, 'UID')).rejects.toMatchInlineSnapshot( - `[Error: Fail]` + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + new Error('Fail') ); + const startContract = userProfileService.start(mockStartParams); + await expect(startContract.get('UID')).rejects.toMatchInlineSnapshot(`[Error: Fail]`); expect(logger.error).toHaveBeenCalled(); }); it('should get user profile and application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); - await expect(startContract.get(requestMock, 'UID', '*')).resolves.toMatchInlineSnapshot(` + await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` Object { "data": Object { "avatar": "fun.gif", @@ -115,9 +107,7 @@ describe('UserProfileService', () => { }, } `); - expect( - mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request - ).toHaveBeenCalledWith({ + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID?data=kibana.*', }); @@ -127,12 +117,10 @@ describe('UserProfileService', () => { describe('#update', () => { it('should update application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); - await startContract.update(requestMock, 'UID', { + await startContract.update('UID', { avatar: 'boring.png', }); - expect( - mockStartParams.clusterClient.asScoped().asCurrentUser.transport.request - ).toHaveBeenCalledWith({ + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ body: { data: { kibana: { @@ -146,12 +134,12 @@ describe('UserProfileService', () => { }); it('should handle errors when update user profile fails', async () => { - mockStartParams.clusterClient - .asScoped() - .asCurrentUser.transport.request.mockRejectedValue(new Error('Fail')); + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + new Error('Fail') + ); const startContract = userProfileService.start(mockStartParams); await expect( - startContract.update(requestMock, 'UID', { + startContract.update('UID', { avatar: 'boring.png', }) ).rejects.toMatchInlineSnapshot(`[Error: Fail]`); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index c68d8fda978c6..08d2094d1b8f8 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IClusterClient, KibanaRequest, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from 'src/core/server'; import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; import { getDetailedErrorMessage } from '../errors'; @@ -25,11 +25,7 @@ export interface UserProfileServiceStart { * @param uid User ID * @param dataPath By default `get()` returns user information, but does not return any user data. The optional "dataPath" parameter can be used to return personal data for this user. */ - get( - request: KibanaRequest, - uid: string, - dataPath?: string - ): Promise>; + get(uid: string, dataPath?: string): Promise>; /** * Updates user preferences by identifier. @@ -37,7 +33,7 @@ export interface UserProfileServiceStart { * @param uid User ID * @param data Application data to be written (merged with existing data). */ - update(request: KibanaRequest, uid: string, data: T): Promise; + update(uid: string, data: T): Promise; } type GetProfileResponse = Record< @@ -87,16 +83,14 @@ export class UserProfileService { } } - async function get(request: KibanaRequest, uid: string, dataPath?: string) { + async function get(uid: string, dataPath?: string) { try { - const body = await clusterClient - .asScoped(request) - .asCurrentUser.transport.request>({ - method: 'GET', - path: `_security/profile/${uid}${ - dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' - }`, - }); + const body = await clusterClient.asInternalUser.transport.request>({ + method: 'GET', + path: `_security/profile/${uid}${ + dataPath ? `?data=${KIBANA_DATA_ROOT}.${dataPath}` : '' + }`, + }); return { ...body[uid], data: body[uid].data[KIBANA_DATA_ROOT] ?? {} }; } catch (error) { logger.error(`Failed to retrieve user profile [uid=${uid}]: ${error.message}`); @@ -104,9 +98,9 @@ export class UserProfileService { } } - async function update(request: KibanaRequest, uid: string, data: T) { + async function update(uid: string, data: T) { try { - await clusterClient.asScoped(request).asCurrentUser.transport.request({ + await clusterClient.asInternalUser.transport.request({ method: 'POST', path: `_security/profile/${uid}/_data`, body: { From 488c489e231ba2ae9c3004bdac5c94c978a2a31c Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 23 Mar 2022 11:10:45 +0000 Subject: [PATCH 18/47] . --- .../account_management_app.test.tsx | 9 +- .../account_management_page.test.tsx | 156 ++++------------- .../user_profile/user_profile.test.tsx | 144 ++++++++++++++++ .../user_profile/user_profile.tsx | 157 +++++++++--------- 4 files changed, 261 insertions(+), 205 deletions(-) create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx index ff0c136a74b76..5b282bb6e435d 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -20,8 +20,6 @@ const AccountManagementPageMock = jest .spyOn(AccountManagementPageImports, 'AccountManagementPage') .mockReturnValue(null); -const element = document.body.appendChild(document.createElement('div')); - describe('accountManagementApp', () => { it('should register application', () => { const { authc } = securityMock.createSetup(); @@ -34,7 +32,7 @@ describe('accountManagementApp', () => { }); expect(application.register).toHaveBeenCalledTimes(1); - expect(application.register).toHaveBeenLastCalledWith( + expect(application.register).toHaveBeenCalledWith( expect.objectContaining({ id: 'security_account', appRoute: '/security/account', @@ -47,8 +45,7 @@ describe('accountManagementApp', () => { it('should render AccountManagementPage on mount', async () => { const { authc } = securityMock.createSetup(); const { application, getStartServices } = coreMock.createSetup(); - const coreStart = coreMock.createStart(); - getStartServices.mockResolvedValue([coreStart, {}, {}]); + getStartServices.mockResolvedValue([coreMock.createStart(), {}, {}]); accountManagementApp.create({ application, @@ -61,7 +58,7 @@ describe('accountManagementApp', () => { let unmount: AppUnmount = noop; await act(async () => { unmount = await mount({ - element, + element: document.createElement('div'), appBasePath: '', onAppLeave: jest.fn(), setHeaderActionMenu: jest.fn(), diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index 8602ff3d5388d..d3778ef97f2c5 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -5,147 +5,55 @@ * 2.0. */ -import { act } from '@testing-library/react'; -import { mount } from 'enzyme'; +import { render } from '@testing-library/react'; import React from 'react'; -import { nextTick } from '@kbn/test-jest-helpers'; import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; -import type { AuthenticatedUser } from '../../common/model'; +import type { UserData } from '../../common/'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { userAPIClientMock } from '../management/users/index.mock'; import { securityMock } from '../mocks'; import { Providers } from './account_management_app'; import { AccountManagementPage } from './account_management_page'; +import * as UserProfileImports from './user_profile/user_profile'; -interface Options { - withFullName?: boolean; - withEmail?: boolean; - realm?: string; -} -const createUser = ({ withFullName = true, withEmail = true, realm = 'native' }: Options = {}) => { - return mockAuthenticatedUser({ - full_name: withFullName ? 'Casey Smith' : '', - username: 'csmith', - email: withEmail ? 'csmith@domain.com' : '', - roles: [], - authentication_realm: { - type: realm, - name: realm, - }, - lookup_realm: { - type: realm, - name: realm, - }, - }); -}; - -function getSecuritySetupMock({ currentUser }: { currentUser: AuthenticatedUser }) { - const securitySetupMock = securityMock.createSetup(); - securitySetupMock.authc.getCurrentUser.mockResolvedValue(currentUser); - return securitySetupMock; -} +const UserProfileMock = jest.spyOn(UserProfileImports, 'UserProfile'); describe('', () => { - it(`displays users full name, username, and email address`, async () => { - const user = createUser(); - const wrapper = mount( - - - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - `Settings for ${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`, async () => { - const user = createUser({ withFullName: false }); - const wrapper = mount( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual( - `Settings for ${user.username}` - ); - }); - - it(`displays a placeholder when no email address is provided`, async () => { - const user = createUser({ withEmail: false }); - const wrapper = mount( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address'); + const coreStart = coreMock.createStart(); + const theme$ = themeServiceMock.createTheme$(); + let history = scopedHistoryMock.create(); + const authc = securityMock.createSetup().authc; + + beforeEach(() => { + history = scopedHistoryMock.create(); + authc.getCurrentUser.mockClear(); + coreStart.http.delete.mockClear(); + coreStart.http.get.mockClear(); + coreStart.http.post.mockClear(); + coreStart.notifications.toasts.addDanger.mockClear(); + coreStart.notifications.toasts.addSuccess.mockClear(); }); - it(`displays change password form for users in the native realm`, async () => { - const user = createUser(); - const wrapper = mount( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); + it('should render user profile form and set breadcrumbs', async () => { + const user = mockAuthenticatedUser(); + const data: UserData = {}; - expect(wrapper.find('EuiFieldPassword[data-test-subj="currentPassword"]')).toHaveLength(1); - expect(wrapper.find('EuiFieldPassword[data-test-subj="newPassword"]')).toHaveLength(1); - }); + authc.getCurrentUser.mockResolvedValue(user); + coreStart.http.get.mockResolvedValue({ user, data }); - it(`does not display change password form for users in the saml realm`, async () => { - const user = createUser({ realm: 'saml' }); - const wrapper = mount( - + const { findByRole } = render( + + + ); - await act(async () => { - await nextTick(); - wrapper.update(); - }); + await findByRole('form'); - expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0); - expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0); + expect(UserProfileMock).toHaveBeenCalledWith({ user, data }, expect.anything()); + expect(coreStart.chrome.setBreadcrumbs).toHaveBeenLastCalledWith([ + { href: '/security/account', text: 'full name' }, + { href: undefined, text: 'Profile' }, + ]); }); }); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx new file mode 100644 index 0000000000000..4894fed685f0e --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; + +import type { UserData } from '../../../common/'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { securityMock } from '../../mocks'; +import { Providers } from '../account_management_app'; +import { useUserProfileForm } from './user_profile'; + +const user = mockAuthenticatedUser(); +const coreStart = coreMock.createStart(); +const theme$ = themeServiceMock.createTheme$(); +let history = scopedHistoryMock.create(); +const authc = securityMock.createSetup().authc; +const wrapper: FunctionComponent = ({ children }) => ( + + {children} + +); + +describe('useUserProfileForm', () => { + beforeEach(() => { + history = scopedHistoryMock.create(); + authc.getCurrentUser.mockReset(); + coreStart.http.delete.mockReset(); + coreStart.http.get.mockReset(); + coreStart.http.post.mockReset(); + coreStart.notifications.toasts.addDanger.mockReset(); + coreStart.notifications.toasts.addSuccess.mockReset(); + }); + + it('should initialise form with values from user profile', () => { + const data: UserData = { + avatar: {}, + }; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + expect(result.current.values).toMatchInlineSnapshot(` + Object { + "avatarType": "initials", + "data": Object { + "avatar": Object { + "color": "#D36086", + "imageUrl": "", + "initials": "fn", + }, + }, + "user": Object { + "email": "email", + "full_name": "full name", + }, + } + `); + }); + + it('should initialise form with values from user avatar if present', () => { + const data: UserData = { + avatar: { + imageUrl: 'avatar.png', + }, + }; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + expect(result.current.values).toEqual( + expect.objectContaining({ + avatarType: 'image', + data: expect.objectContaining({ + avatar: expect.objectContaining({ + imageUrl: 'avatar.png', + }), + }), + }) + ); + }); + + it('should update initials when full name changes', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.setFieldValue('user.full_name', 'Another Name'); + }); + + expect(result.current.values.user.full_name).toEqual('Another Name'); + expect(result.current.values.data.avatar.initials).toEqual('AN'); + }); + + it('should save user and user profile when submitting form', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.http.post).toHaveBeenCalledTimes(2); + }); + + it('should add toast after submitting form successfully', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledTimes(1); + }); + + it('should add toast after submitting form failed', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + coreStart.http.post.mockRejectedValue(new Error('Error')); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.notifications.toasts.addError).toHaveBeenCalledTimes(1); + }); + + it('should set initial values to current values after submitting form successfully', async () => { + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.setFieldValue('user.full_name', 'Another Name'); + await result.current.submitForm(); + }); + + expect(result.current.initialValues.user.full_name).toEqual('Another Name'); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 659fdaabf4c23..94c8c0a6a776c 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -23,6 +23,7 @@ import { EuiSpacer, EuiSplitPanel, EuiToolTip, + useGeneratedHtmlId, } from '@elastic/eui'; import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; import type { FunctionComponent } from 'react'; @@ -79,81 +80,9 @@ export interface UserProfileFormValues { } export const UserProfile: FunctionComponent = ({ user, data }) => { - const { services } = useKibana(); - const [initialValues, resetInitialValues] = useState({ - user: { - full_name: user.full_name || '', - email: user.email || '', - }, - data: { - avatar: { - initials: data.avatar?.initials || getUserAvatarInitials(user), - color: data.avatar?.color || getUserAvatarColor(user), - imageUrl: data.avatar?.imageUrl || '', - }, - }, - avatarType: data.avatar?.imageUrl ? 'image' : 'initials', - }); - - const formik = useFormik({ - onSubmit: async (values) => { - try { - await Promise.all([ - new UserAPIClient(services.http).saveUser({ - username: user.username, - roles: user.roles, - enabled: user.enabled, - full_name: values.user.full_name, - email: values.user.email, - }), - new UserProfileAPIClient(services.http).update( - values.avatarType === 'image' - ? values.data - : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } - ), - ]); - resetInitialValues(values); - services.notifications.toasts.addSuccess( - i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { - defaultMessage: 'Profile updated', - }) - ); - } catch (error) { - services.notifications.toasts.addError(error, { - title: i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { - defaultMessage: "Couldn't update profile", - }), - }); - } - }, - initialValues, - enableReinitialize: true, - }); - + const formik = useUserProfileForm({ user, data }); const formChanges = useFormChanges(); - - const customAvatarInitials = useRef( - !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) - ); - - useUpdateEffect(() => { - if (!customAvatarInitials.current) { - const defaultInitials = getUserAvatarInitials({ - username: user.username, - full_name: formik.values.user.full_name, - }); - formik.setFieldValue('data.avatar.initials', defaultInitials); - } - }, [formik.values.user.full_name]); - - useUpdateEffect(() => { - const defaultInitials = getUserAvatarInitials({ - username: user.username, - full_name: formik.values.user.full_name, - }); - customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; - }, [formik.values.data.avatar.initials]); - + const titleId = useGeneratedHtmlId(); const [showDeleteFlyout, setShowDeleteFlyout] = useState(false); const isReservedUser = isUserReserved(user); @@ -182,6 +111,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Profile" /> ), + pageTitleProps: { id: titleId }, rightSideItems: [ canChangePassword ? ( setShowDeleteFlyout(true)} iconType="lock" fill> @@ -227,7 +157,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) ) : null} -
+ = ({ user, data }) ); }; +export function useUserProfileForm({ user, data }: UserProfileProps) { + const { services } = useKibana(); + const [initialValues, resetInitialValues] = useState({ + user: { + full_name: user.full_name || '', + email: user.email || '', + }, + data: { + avatar: { + initials: data.avatar?.initials || getUserAvatarInitials(user), + color: data.avatar?.color || getUserAvatarColor(user), + imageUrl: data.avatar?.imageUrl || '', + }, + }, + avatarType: data.avatar?.imageUrl ? 'image' : 'initials', + }); + + const formik = useFormik({ + onSubmit: async (values) => { + try { + await Promise.all([ + new UserAPIClient(services.http).saveUser({ + username: user.username, + roles: user.roles, + enabled: user.enabled, + full_name: values.user.full_name, + email: values.user.email, + }), + new UserProfileAPIClient(services.http).update( + values.avatarType === 'image' + ? values.data + : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } + ), + ]); + resetInitialValues(values); + services.notifications.toasts.addSuccess( + i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + defaultMessage: 'Profile updated', + }) + ); + } catch (error) { + services.notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + defaultMessage: "Couldn't update profile", + }), + }); + } + }, + initialValues, + enableReinitialize: true, + }); + + const customAvatarInitials = useRef( + !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) + ); + + useUpdateEffect(() => { + if (!customAvatarInitials.current) { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + formik.setFieldValue('data.avatar.initials', defaultInitials); + } + }, [formik.values.user.full_name]); + + useUpdateEffect(() => { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; + }, [formik.values.data.avatar.initials]); + + return formik; +} + export const SaveChangesBottomBar: FunctionComponent = () => { const formik = useFormikContext(); const { count } = useFormChangesContext(); From 4e7e819bd83abf0332e2682d07dae1735c095617 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 14:15:51 +0100 Subject: [PATCH 19/47] . --- .../common/model/user_profile.mock.ts | 27 +- .../security/common/model/user_profile.ts | 7 +- .../user_profile/user_profile.test.tsx | 1 + .../user_profile_api_client.test.ts | 42 ++ x-pack/plugins/security/public/index.ts | 3 +- .../nav_control/nav_control_component.scss | 11 - .../nav_control_component.test.tsx | 523 ++++++++++++------ .../nav_control/nav_control_component.tsx | 27 +- .../nav_control/nav_control_service.tsx | 38 +- x-pack/plugins/security/public/plugin.tsx | 1 + .../change_password/change_password.tsx | 0 .../change_password/change_password_async.tsx | 0 .../change_password/index.ts | 0 .../security/public/ui_api/components.tsx | 4 +- .../plugins/security/public/ui_api/index.ts | 5 +- .../personal_info/index.ts | 0 .../personal_info/personal_info.tsx | 0 .../personal_info/personal_info_async.tsx | 0 .../server/routes/user_profile/get.ts | 2 +- 19 files changed, 451 insertions(+), 240 deletions(-) create mode 100644 x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts delete mode 100644 x-pack/plugins/security/public/nav_control/nav_control_component.scss rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/change_password.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/change_password_async.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/change_password/index.ts (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/index.ts (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/personal_info.tsx (100%) rename x-pack/plugins/security/public/{account_management => ui_api}/personal_info/personal_info_async.tsx (100%) diff --git a/x-pack/plugins/security/common/model/user_profile.mock.ts b/x-pack/plugins/security/common/model/user_profile.mock.ts index 63573b62215f5..fa6f34a1b603e 100644 --- a/x-pack/plugins/security/common/model/user_profile.mock.ts +++ b/x-pack/plugins/security/common/model/user_profile.mock.ts @@ -5,14 +5,25 @@ * 2.0. */ -import type { UserProfile } from './user_profile'; +import { mockAuthenticatedUser } from './authenticated_user.mock'; +import type { AuthenticatedUserProfile } from './user_profile'; export const userProfileMock = { - create: (userProfile: Partial = {}): UserProfile => ({ - uid: 'some-profile-uid', - enabled: true, - user: { username: 'some-username', active: true, roles: [], enabled: true }, - data: {}, - ...userProfile, - }), + create: (userProfile: Partial = {}): AuthenticatedUserProfile => { + const user = mockAuthenticatedUser({ + username: 'some-username', + roles: [], + enabled: true, + }); + return { + uid: 'some-profile-uid', + enabled: true, + user: { + ...user, + active: true, + }, + data: {}, + ...userProfile, + }; + }, }; diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index e741ea56cf09e..a606d1ea0faa3 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -7,7 +7,8 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; -import type { AuthenticationProvider, User } from '../'; +import type { User } from '../'; +import type { AuthenticatedUser } from './authenticated_user'; import { getUserDisplayName } from './user'; /** @@ -61,9 +62,9 @@ export interface UserProfile { */ export interface AuthenticatedUserProfile extends UserProfile { /** - * Authentication provider of this user's session. + * Information about the currently authenticated user that owns the profile. */ - authentication_provider: AuthenticationProvider; + user: UserProfile['user'] & Pick; } export const USER_AVATAR_FALLBACK_CODE_POINT = 97; // code point for lowercase "a" diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 4894fed685f0e..1847ddc801d68 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -22,6 +22,7 @@ const coreStart = coreMock.createStart(); const theme$ = themeServiceMock.createTheme$(); let history = scopedHistoryMock.create(); const authc = securityMock.createSetup().authc; + const wrapper: FunctionComponent = ({ children }) => ( {children} diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts new file mode 100644 index 0000000000000..c262581bf9656 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { coreMock } from 'src/core/public/mocks'; + +import { UserProfileAPIClient } from './user_profile_api_client'; + +const coreStart = coreMock.createStart(); +const apiClient = new UserProfileAPIClient(coreStart.http); + +describe('UserProfileAPIClient', () => { + beforeEach(() => { + coreStart.http.delete.mockReset(); + coreStart.http.get.mockReset(); + coreStart.http.post.mockReset(); + }); + + it('should get user profile without retrieving any user data', async () => { + await apiClient.get(); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', { + query: { data: undefined }, + }); + }); + + it('should get user profile and user data', async () => { + await apiClient.get('*'); + expect(coreStart.http.get).toHaveBeenCalledWith('/internal/security/user_profile', { + query: { data: '*' }, + }); + }); + + it('should update user data', async () => { + await apiClient.update({ avatar: { imageUrl: 'avatar.png' } }); + expect(coreStart.http.post).toHaveBeenCalledWith('/internal/security/user_profile/_data', { + body: '{"avatar":{"imageUrl":"avatar.png"}}', + }); + }); +}); diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 552442c0b8611..7fdd72126cc18 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -19,8 +19,7 @@ export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; export type { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; export type { UserMenuLink, SecurityNavControlServiceStart } from '../public/nav_control'; -export type { UiApi } from './ui_api'; -export type { PersonalInfoProps, ChangePasswordProps } from './account_management'; +export type { UiApi, ChangePasswordProps, PersonalInfoProps } from './ui_api'; export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.scss b/x-pack/plugins/security/public/nav_control/nav_control_component.scss deleted file mode 100644 index a3e04b08cfac2..0000000000000 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.scss +++ /dev/null @@ -1,11 +0,0 @@ -.chrNavControl__userMenu { - .euiContextMenuPanelTitle { - // Uppercased by default, override to match actual username - text-transform: none; - } - - .euiContextMenuItem { - // Temp fix for EUI issue https://github.com/elastic/eui/issues/3092 - line-height: normal; - } -} \ No newline at end of file diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index be74cca3d2671..8da929130869f 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,28 +5,63 @@ * 2.0. */ -import { EuiContextMenuItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; +import { EuiContextMenu } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import type { FunctionComponent, ReactElement } from 'react'; import React from 'react'; +import { act } from 'react-dom/test-utils'; +import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject } from 'rxjs'; -import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; +import { coreMock, themeServiceMock } from 'src/core/public/mocks'; -import type { AuthenticatedUser } from '../../common/model'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { userProfileMock } from '../../common/model/user_profile.mock'; +import * as UseCurrentUserImports from '../components/use_current_user'; import { SecurityNavControl } from './nav_control_component'; +import { Providers } from './nav_control_service'; + +jest.mock('../components/use_current_user'); +jest.mock('react-use/lib/useObservable'); + +const useObservableMock = useObservable as jest.Mock; +const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); + +const userProfile = userProfileMock.create(); +const coreStart = coreMock.createStart(); +const theme$ = themeServiceMock.createTheme$(); +const userMenuLinks$ = new BehaviorSubject([]); + +const wrappingComponent: FunctionComponent = ({ children }) => ( + + {children} + +); describe('SecurityNavControl', () => { - it(`renders a loading spinner when the user promise hasn't resolved yet.`, async () => { - const props = { - user: new Promise(() => mockAuthenticatedUser()), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = shallowWithIntl(); - const { button } = wrapper.find(EuiPopover).props(); - expect(button).toMatchInlineSnapshot(` + beforeEach(() => { + useUserProfileMock.mockReset(); + useUserProfileMock.mockReturnValue({ + loading: false, + value: userProfile, + }); + + useObservableMock.mockReset(); + useObservableMock.mockImplementation( + (observable: BehaviorSubject, initialValue = {}) => observable.value ?? initialValue + ); + }); + + it('should render an avatar when user profile has loaded', async () => { + const wrapper = shallow( + , + { + wrappingComponent, + } + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { data-test-subj="userMenuButton" onClick={[Function]} > - `); }); - it(`renders an avatar after the user promise resolves.`, async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = shallowWithIntl(); - await nextTick(); - wrapper.update(); - const { button } = wrapper.find(EuiPopover).props(); - expect(button).toMatchInlineSnapshot(` + it('should render a spinner while loading', () => { + useUserProfileMock.mockReturnValue({ + loading: true, + }); + + const wrapper = shallow( + , + { + wrappingComponent, + } + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { data-test-subj="userMenuButton" onClick={[Function]} > - `); }); - it(`doesn't render the popover when the user hasn't been loaded yet`, async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; - - const wrapper = mountWithIntl(); - // not awaiting the user promise - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + it('should open popover when avatar is clicked', async () => { + const wrapper = shallow( + , + { + wrappingComponent, + } + ); - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + act(() => { + wrapper.prop('button').props.onClick(); + wrapper.update(); + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + expect(wrapper.prop('isOpen')).toEqual(true); }); - it('renders a popover when the avatar is clicked.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([]), - }; + it('should not open popover while loading', () => { + useUserProfileMock.mockReturnValue({ + loading: true, + }); - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); + const wrapper = shallow( + , + { + wrappingComponent, + } + ); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + act(() => { + wrapper.prop('button').props.onClick(); + wrapper.update(); + }); - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); - }); - - it('renders a popover with additional user menu links registered by other plugins', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(0); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(1); - expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + expect(wrapper.prop('isOpen')).toEqual(false); }); - it('properly renders a popover for anonymous user.', async () => { - const props = { - user: Promise.resolve( - mockAuthenticatedUser({ - authentication_provider: { type: 'anonymous', name: 'does no matter' }, - }) - ), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); - - expect(findTestSubject(wrapper, 'logoutLink').text()).toBe('Log in'); + it('should render additional user menu links registered by other plugins', async () => { + const wrapper = shallow( + , + { + wrappingComponent, + } + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "profileLink", + "href": "", + "icon": , + "name": , + }, + Object { + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); - it('properly renders without a custom profile link.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ - 'Profile', - 'link1', - 'link2', - 'Log out', - ]); + it('should render custom profile link registered by other plugins', async () => { + const wrapper = shallow( + , + { + wrappingComponent, + } + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "data-test-subj": "profileLink", + "href": "", + "icon": , + "name": , + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); - it('properly renders with a custom profile link.', async () => { - const props = { - user: Promise.resolve(mockAuthenticatedUser({ full_name: 'foo' })), - editProfileUrl: '', - logoutUrl: '', - userMenuLinks$: new BehaviorSubject([ - { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, - { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2, setAsProfile: true }, - ]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([]); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - expect(wrapper.find(EuiContextMenuItem).map((node) => node.text())).toEqual([ - 'link1', - 'link2', - 'Preferences', - 'Log out', - ]); + it('should render anonymous user', async () => { + useUserProfileMock.mockReturnValue({ + loading: false, + value: userProfileMock.create({ + user: { + ...mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, + }), + active: true, + }, + }), + }); + + const wrapper = shallow( + , + { + wrappingComponent, + } + ); + + expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` + Array [ + Object { + "id": 0, + "items": Array [ + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ], + "title": "full name", + }, + ] + `); }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 9ad96a3e13885..856c34aa2b8bf 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -5,8 +5,6 @@ * 2.0. */ -import './nav_control_component.scss'; - import type { EuiContextMenuPanelItemDescriptor, IconType } from '@elastic/eui'; import { EuiContextMenu, @@ -46,7 +44,7 @@ export const SecurityNavControl: FunctionComponent = ({ logoutUrl, userMenuLinks$, }) => { - const userMenuLinks = useObservable(userMenuLinks$) ?? []; + const userMenuLinks = useObservable(userMenuLinks$, []); const [isOpen, setIsOpen] = useState(false); const userProfile = useUserProfile(); @@ -77,9 +75,8 @@ export const SecurityNavControl: FunctionComponent = ({ ); - const isAnonymous = userProfile.value ? isUserAnonymous(userProfile.value) : false; + const isAnonymous = userProfile.value ? isUserAnonymous(userProfile.value.user) : false; const items: EuiContextMenuPanelItemDescriptor[] = []; - if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) @@ -132,14 +129,6 @@ export const SecurityNavControl: FunctionComponent = ({ 'data-test-subj': 'logoutLink', }); - const panels = [ - { - id: 0, - title: displayName, - items, - }, - ]; - return ( = ({ buffer={0} >
- +
); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 21cf0116a29f6..413094fad1c24 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -6,6 +6,7 @@ */ import { sortBy } from 'lodash'; +import type { FunctionComponent } from 'react'; import React from 'react'; import ReactDOM from 'react-dom'; import type { Observable, Subscription } from 'rxjs'; @@ -13,7 +14,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { I18nProvider } from '@kbn/i18n-react'; -import type { CoreStart } from 'src/core/public'; +import type { CoreStart, CoreTheme } from 'src/core/public'; import { KibanaContextProvider, @@ -112,25 +113,19 @@ export class SecurityNavControlService { this.stop$.next(); } - private registerSecurityNavControl( - core: Pick - ) { + private registerSecurityNavControl(core: CoreStart) { const { theme$ } = core.theme; core.chrome.navControls.registerRight({ order: 2000, mount: (element: HTMLElement) => { ReactDOM.render( - - - - - - - , + + + , element ); @@ -145,3 +140,16 @@ export class SecurityNavControlService { return sortBy(userMenuLinks, 'order'); } } + +export interface ProvidersProps { + services: CoreStart; + theme$: Observable; +} + +export const Providers: FunctionComponent = ({ services, theme$, children }) => ( + + + {children} + + +); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index c2860ec059b8d..6a4af685bf342 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -201,6 +201,7 @@ export interface SecurityPluginStart { authc: AuthenticationServiceStart; /** * Exposes UI components that will be loaded asynchronously. + * @deprecated */ uiApi: UiApi; } diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password.tsx b/x-pack/plugins/security/public/ui_api/change_password/change_password.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/change_password.tsx rename to x-pack/plugins/security/public/ui_api/change_password/change_password.tsx diff --git a/x-pack/plugins/security/public/account_management/change_password/change_password_async.tsx b/x-pack/plugins/security/public/ui_api/change_password/change_password_async.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/change_password_async.tsx rename to x-pack/plugins/security/public/ui_api/change_password/change_password_async.tsx diff --git a/x-pack/plugins/security/public/account_management/change_password/index.ts b/x-pack/plugins/security/public/ui_api/change_password/index.ts similarity index 100% rename from x-pack/plugins/security/public/account_management/change_password/index.ts rename to x-pack/plugins/security/public/ui_api/change_password/index.ts diff --git a/x-pack/plugins/security/public/ui_api/components.tsx b/x-pack/plugins/security/public/ui_api/components.tsx index a488bc359b538..f07d6777d39e8 100644 --- a/x-pack/plugins/security/public/ui_api/components.tsx +++ b/x-pack/plugins/security/public/ui_api/components.tsx @@ -18,9 +18,9 @@ import type { CoreStart } from 'src/core/public'; * It happens because the bundle starts to also include all the sync dependencies * available through the index file. */ -import { getChangePasswordComponent } from '../account_management/change_password/change_password_async'; -import { getPersonalInfoComponent } from '../account_management/personal_info/personal_info_async'; +import { getChangePasswordComponent } from './change_password/change_password_async'; import { LazyWrapper } from './lazy_wrapper'; +import { getPersonalInfoComponent } from './personal_info/personal_info_async'; export interface GetComponentsOptions { core: CoreStart; diff --git a/x-pack/plugins/security/public/ui_api/index.ts b/x-pack/plugins/security/public/ui_api/index.ts index e53564074940a..507e125896ffa 100644 --- a/x-pack/plugins/security/public/ui_api/index.ts +++ b/x-pack/plugins/security/public/ui_api/index.ts @@ -9,8 +9,11 @@ import type { ReactElement } from 'react'; import type { CoreStart } from 'src/core/public'; -import type { ChangePasswordProps, PersonalInfoProps } from '../account_management'; +import type { ChangePasswordProps } from './change_password'; import { getComponents } from './components'; +import type { PersonalInfoProps } from './personal_info'; + +export type { ChangePasswordProps, PersonalInfoProps }; interface GetUiApiOptions { core: CoreStart; diff --git a/x-pack/plugins/security/public/account_management/personal_info/index.ts b/x-pack/plugins/security/public/ui_api/personal_info/index.ts similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/index.ts rename to x-pack/plugins/security/public/ui_api/personal_info/index.ts diff --git a/x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx b/x-pack/plugins/security/public/ui_api/personal_info/personal_info.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/personal_info.tsx rename to x-pack/plugins/security/public/ui_api/personal_info/personal_info.tsx diff --git a/x-pack/plugins/security/public/account_management/personal_info/personal_info_async.tsx b/x-pack/plugins/security/public/ui_api/personal_info/personal_info_async.tsx similarity index 100% rename from x-pack/plugins/security/public/account_management/personal_info/personal_info_async.tsx rename to x-pack/plugins/security/public/ui_api/personal_info/personal_info_async.tsx diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 06e8dfe6410ec..a4273a01f4768 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -38,7 +38,7 @@ export function defineGetUserProfileRoute({ const profile = await userProfileService.get(session.userProfileId, request.query.data); const body: AuthenticatedUserProfile = { ...profile, - authentication_provider: session.provider, + user: { ...profile.user, authentication_provider: session.provider }, }; return response.ok({ body }); } catch (error) { From 31a1c3523ca7db0785463caf92a7f8a25fd8cad1 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 15:31:13 +0100 Subject: [PATCH 20/47] . --- .../nav_control/nav_control_service.test.ts | 34 +--- .../nav_control/nav_control_service.tsx | 2 - .../user_profile/user_profile_service.test.ts | 176 ++++++++++++------ 3 files changed, 134 insertions(+), 78 deletions(-) diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 21352f16d2354..fda7ac102e6b0 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -13,8 +13,15 @@ import { coreMock } from 'src/core/public/mocks'; import type { ILicense } from '../../../licensing/public'; import { SecurityLicenseService } from '../../common/licensing'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import * as UseCurrentUserImports from '../components/use_current_user'; import { securityMock } from '../mocks'; import { SecurityNavControlService } from './nav_control_service'; +const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); + +useUserProfileMock.mockReset(); +useUserProfileMock.mockReturnValue({ + loading: true, +}); const validLicense = { isAvailable: true, @@ -34,13 +41,8 @@ describe('SecurityNavControlService', () => { const license$ = new BehaviorSubject(validLicense); const navControlService = new SecurityNavControlService(); - const mockSecuritySetup = securityMock.createSetup(); - mockSecuritySetup.authc.getCurrentUser.mockResolvedValue( - mockAuthenticatedUser({ username: 'some-user', full_name: undefined }) - ); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: mockSecuritySetup.authc, logoutUrl: '/some/logout/url', }); @@ -83,20 +85,9 @@ describe('SecurityNavControlService', () => { - + @@ -117,7 +108,6 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); @@ -137,7 +127,6 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); @@ -154,7 +143,6 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); @@ -176,7 +164,6 @@ describe('SecurityNavControlService', () => { const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); @@ -199,7 +186,6 @@ describe('SecurityNavControlService', () => { navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 413094fad1c24..2687d56816b5e 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -21,13 +21,11 @@ import { KibanaThemeProvider, } from '../../../../../src/plugins/kibana_react/public'; import type { SecurityLicense } from '../../common/licensing'; -import type { AuthenticationServiceSetup } from '../authentication'; import type { UserMenuLink } from './nav_control_component'; import { SecurityNavControl } from './nav_control_component'; interface SetupDeps { securityLicense: SecurityLicense; - authc: AuthenticationServiceSetup; logoutUrl: string; } diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index ed55ca335fddb..ce87ea46acb89 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -61,20 +61,38 @@ describe('UserProfileService', () => { it('should get user profile', async () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.get('UID')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "enabled": true, - "uid": "UID", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID', @@ -93,20 +111,38 @@ describe('UserProfileService', () => { it('should get user profile and application data scoped to Kibana', async () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.get('UID', '*')).resolves.toMatchInlineSnapshot(` - Object { - "data": Object { - "avatar": "fun.gif", - }, - "enabled": true, - "uid": "UID", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object { + "avatar": "fun.gif", + }, + "enabled": true, + "uid": "UID", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ method: 'GET', path: '_security/profile/UID?data=kibana.*', @@ -163,18 +199,36 @@ describe('UserProfileService', () => { password: 'password', }) ).resolves.toMatchInlineSnapshot(` - Object { - "data": Object {}, - "enabled": true, - "uid": "some-profile-uid", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( 1 ); @@ -189,18 +243,36 @@ describe('UserProfileService', () => { const startContract = userProfileService.start(mockStartParams); await expect(startContract.activate({ type: 'accessToken', accessToken: 'some-token' })) .resolves.toMatchInlineSnapshot(` - Object { - "data": Object {}, - "enabled": true, - "uid": "some-profile-uid", - "user": Object { - "active": true, - "enabled": true, - "roles": Array [], - "username": "some-username", - }, - } - `); + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( 1 ); From 5ce63e629d16812cc5a18d71f0991d98ff4e07a6 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 15:55:35 +0100 Subject: [PATCH 21/47] . --- .../users/edit_user/change_password_flyout.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx index 9b53e06bd318e..5662d6363ba22 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx @@ -72,7 +72,7 @@ export const validateChangePasswordForm = ( errors.password = i18n.translate( 'xpack.security.management.users.changePasswordFlyout.passwordInvalidError', { - defaultMessage: 'Password must be at least 6 characters.', + defaultMessage: 'Enter at least 6 characters.', } ); } else if (values.password !== values.confirm_password) { @@ -258,7 +258,12 @@ export const ChangePasswordFlyout: FunctionComponent defaultMessage: 'New password', } )} - helpText="Password must be at least 6 characters." + helpText={i18n.translate( + 'xpack.security.management.users.changePasswordFlyout.passwordHelpText', + { + defaultMessage: 'Password must be at least 6 characters.', + } + )} error={form.errors.password} isInvalid={form.touched.password && !!form.errors.password} > From c58ff850348a1d70a3f747e943f6dec8090fa1b0 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 15:56:40 +0100 Subject: [PATCH 22/47] . --- .../users/edit_user/change_password_flyout.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx index b0b8ca2030fa0..ff97d8dcbb337 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx @@ -41,11 +41,11 @@ describe('ChangePasswordFlyout', () => { it('should show errors when the new password is not at least 6 characters', () => { expect(validateChangePasswordForm({ password: '12345', confirm_password: '12345' }, true)) .toMatchInlineSnapshot(` - Object { - "current_password": "Enter your current password.", - "password": "Password must be at least 6 characters.", - } - `); + Object { + "current_password": "Enter your current password.", + "password": "Enter at least 6 characters.", + } + `); }); it('should show errors when new password does not match confirmation password', () => { @@ -100,7 +100,7 @@ describe('ChangePasswordFlyout', () => { expect(validateChangePasswordForm({ password: '1234', confirm_password: '1234' }, false)) .toMatchInlineSnapshot(` Object { - "password": "Password must be at least 6 characters.", + "password": "Enter at least 6 characters.", } `); }); From c4c6d36c9b439e5e48660ccbc071e5721ca007f4 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 16:19:33 +0100 Subject: [PATCH 23/47] . --- .../account_management/user_profile/user_profile.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 94c8c0a6a776c..9a95ef9c83886 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -536,11 +536,13 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { }, [formik.values.user.full_name]); useUpdateEffect(() => { - const defaultInitials = getUserAvatarInitials({ - username: user.username, - full_name: formik.values.user.full_name, - }); - customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; + if (!customAvatarInitials.current) { + const defaultInitials = getUserAvatarInitials({ + username: user.username, + full_name: formik.values.user.full_name, + }); + customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; + } }, [formik.values.data.avatar.initials]); return formik; From 2bd88757183fbad1a7de31e3abe8f1de0dc377a8 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 19:43:35 +0100 Subject: [PATCH 24/47] . --- .../common/model/authenticated_user.test.ts | 83 ++++++++++++++++++- .../common/model/authenticated_user.ts | 9 ++ x-pack/plugins/security/common/model/index.ts | 2 +- .../user_profile/user_profile.tsx | 32 ++++--- .../public/components/form_row.test.tsx | 6 +- .../nav_control/nav_control_service.test.ts | 2 - x-pack/plugins/security/public/plugin.tsx | 1 - 7 files changed, 116 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index 86a976daf7bf6..74bff7a4d8958 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -5,8 +5,89 @@ * 2.0. */ +import { applicationServiceMock } from 'src/core/public/mocks'; + import type { AuthenticatedUser } from './authenticated_user'; -import { canUserChangePassword } from './authenticated_user'; +import { canUserChangeDetails, canUserChangePassword, isUserAnonymous } from './authenticated_user'; +import { mockAuthenticatedUser } from './authenticated_user.mock'; + +describe('canUserChangeDetails', () => { + const { capabilities } = applicationServiceMock.createStartContract(); + + it('should indicate when user can change their details', () => { + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'native', name: 'native1' }, + }), + { + ...capabilities, + management: { + security: { + users: true, + }, + }, + } + ) + ).toBe(true); + }); + + it('should indicate when user cannot change their details', () => { + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'native', name: 'native1' }, + }), + { + ...capabilities, + management: { + security: { + users: false, + }, + }, + } + ) + ).toBe(false); + + expect( + canUserChangeDetails( + mockAuthenticatedUser({ + authentication_realm: { type: 'reserved', name: 'reserved1' }, + }), + { + ...capabilities, + management: { + security: { + users: true, + }, + }, + } + ) + ).toBe(false); + }); +}); + +describe('isUserAnonymous', () => { + it('should indicate anonymous user', () => { + expect( + isUserAnonymous( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'basic1' }, + }) + ) + ).toBe(true); + }); + + it('should indicate non-anonymous user', () => { + expect( + isUserAnonymous( + mockAuthenticatedUser({ + authentication_provider: { type: 'basic', name: 'basic1' }, + }) + ) + ).toBe(false); + }); +}); describe('#canUserChangePassword', () => { ['reserved', 'native'].forEach((realm) => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index 0ccd0abf9fc48..c974da29b5646 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { Capabilities } from 'src/core/types'; + import type { AuthenticationProvider } from './authentication_provider'; import type { User } from './user'; @@ -54,3 +56,10 @@ export function canUserChangePassword( !isUserAnonymous(user) ); } + +export function canUserChangeDetails( + user: Pick, + capabilities: Capabilities +) { + return user.authentication_realm.type === 'native' && capabilities.management.security.users; +} diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 7aec9a3021d02..f4e93d846e476 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -21,7 +21,7 @@ export { } from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; -export { canUserChangePassword, isUserAnonymous } from './authenticated_user'; +export { canUserChangePassword, canUserChangeDetails, isUserAnonymous } from './authenticated_user'; export type { AuthenticationProvider } from './authentication_provider'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 9a95ef9c83886..36236e274c743 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -37,6 +37,7 @@ import type { CoreStart } from 'src/core/public'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; import { + canUserChangeDetails, canUserChangePassword, getUserAvatarColor, getUserAvatarInitials, @@ -80,6 +81,7 @@ export interface UserProfileFormValues { } export const UserProfile: FunctionComponent = ({ user, data }) => { + const { services } = useKibana(); const formik = useUserProfileForm({ user, data }); const formChanges = useFormChanges(); const titleId = useGeneratedHtmlId(); @@ -87,6 +89,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) const isReservedUser = isUserReserved(user); const canChangePassword = canUserChangePassword(user); + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); return ( @@ -208,7 +211,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) } labelAppend={} - isDisabled={isReservedUser} + isDisabled={!canChangeDetails} fullWidth > @@ -224,7 +227,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) } labelAppend={} - isDisabled={isReservedUser} + isDisabled={!canChangeDetails} fullWidth > @@ -471,6 +474,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) export function useUserProfileForm({ user, data }: UserProfileProps) { const { services } = useKibana(); + const [initialValues, resetInitialValues] = useState({ user: { full_name: user.full_name || '', @@ -489,20 +493,26 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { const formik = useFormik({ onSubmit: async (values) => { try { - await Promise.all([ - new UserAPIClient(services.http).saveUser({ - username: user.username, - roles: user.roles, - enabled: user.enabled, - full_name: values.user.full_name, - email: values.user.email, - }), + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + const promises = [ new UserProfileAPIClient(services.http).update( values.avatarType === 'image' ? values.data : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } ), - ]); + ]; + if (canChangeDetails) { + promises.push( + new UserAPIClient(services.http).saveUser({ + username: user.username, + roles: user.roles, + enabled: user.enabled, + full_name: values.user.full_name, + email: values.user.email, + }) + ); + } + await Promise.all(promises); resetInitialValues(values); services.notifications.toasts.addSuccess( i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { diff --git a/x-pack/plugins/security/public/components/form_row.test.tsx b/x-pack/plugins/security/public/components/form_row.test.tsx index ad2a5818fca3e..962bf5e79f974 100644 --- a/x-pack/plugins/security/public/components/form_row.test.tsx +++ b/x-pack/plugins/security/public/components/form_row.test.tsx @@ -19,7 +19,7 @@ describe('FormRow', () => { { error: 'Error', touched: false, isInvalid: false }, { error: undefined, touched: true, isInvalid: false }, ]; - assertions.forEach(({error, touched, isInvalid}) => { + assertions.forEach(({ error, touched, isInvalid }) => { const wrapper = mount( { ); - + expect(wrapper.find(EuiFormRow).props()).toEqual( expect.objectContaining({ error, isInvalid, }) ); - }) + }); }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index fda7ac102e6b0..f6f974ed6b1d8 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -12,9 +12,7 @@ import { coreMock } from 'src/core/public/mocks'; import type { ILicense } from '../../../licensing/public'; import { SecurityLicenseService } from '../../common/licensing'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import * as UseCurrentUserImports from '../components/use_current_user'; -import { securityMock } from '../mocks'; import { SecurityNavControlService } from './nav_control_service'; const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 6a4af685bf342..81f80c8199685 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -98,7 +98,6 @@ export class SecurityPlugin this.navControlService.setup({ securityLicense: license, - authc: this.authc, logoutUrl, }); From c5a4b71fb13768cb5fa4a26d49facb4cc6cb1329 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Mon, 28 Mar 2022 22:21:17 +0100 Subject: [PATCH 25/47] . --- .../user_profile/user_profile.test.tsx | 28 +++++++++++++++++++ .../user_profile/user_profile.tsx | 28 ++++++++++++------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 1847ddc801d68..65b778bcf554e 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -33,6 +33,14 @@ describe('useUserProfileForm', () => { beforeEach(() => { history = scopedHistoryMock.create(); authc.getCurrentUser.mockReset(); + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: true, + }, + }, + }; coreStart.http.delete.mockReset(); coreStart.http.get.mockReset(); coreStart.http.post.mockReset(); @@ -107,6 +115,26 @@ describe('useUserProfileForm', () => { expect(coreStart.http.post).toHaveBeenCalledTimes(2); }); + it("should save user profile only when user details can't be updated", async () => { + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: false, + }, + }, + }; + + const data: UserData = {}; + const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); + + await act(async () => { + await result.current.submitForm(); + }); + + expect(coreStart.http.post).toHaveBeenCalledTimes(1); + }); + it('should add toast after submitting form successfully', async () => { const data: UserData = {}; const { result } = renderHook(() => useUserProfileForm({ user, data }), { wrapper }); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 36236e274c743..c6dac88f52c54 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -186,14 +186,6 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Username" /> } - helpText={ - !isReservedUser ? ( - - ) : null - } fullWidth isDisabled > @@ -210,7 +202,15 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> } - labelAppend={} + helpText={ + !canChangeDetails ? ( + + ) : null + } + labelAppend={canChangeDetails ? : null} isDisabled={!canChangeDetails} fullWidth > @@ -226,7 +226,15 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> } - labelAppend={} + helpText={ + !canChangeDetails ? ( + + ) : null + } + labelAppend={canChangeDetails ? : null} isDisabled={!canChangeDetails} fullWidth > From 833a8c76677f2f3f488c0fabe5235c9134cc600b Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 29 Mar 2022 09:14:19 +0100 Subject: [PATCH 26/47] . --- .../public/account_management/user_profile/user_profile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index c6dac88f52c54..98f3a5a62bd05 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -127,7 +127,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) } @@ -523,13 +523,13 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { await Promise.all(promises); resetInitialValues(values); services.notifications.toasts.addSuccess( - i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { defaultMessage: 'Profile updated', }) ); } catch (error) { services.notifications.toasts.addError(error, { - title: i18n.translate('xpack.spaces.management.customizeSpaceAvatar.initialsHelpText', { + title: i18n.translate('xpack.security.accountManagement.userProfile.submitErrorTitle', { defaultMessage: "Couldn't update profile", }), }); From c6ec506382086603bee6b4ce1062401bf22e6283 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 29 Mar 2022 09:36:24 +0100 Subject: [PATCH 27/47] . --- .../account_management/account_management_page.test.tsx | 8 ++++++++ .../security/public/components/use_current_user.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index d3778ef97f2c5..d25358614551c 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -21,6 +21,14 @@ const UserProfileMock = jest.spyOn(UserProfileImports, 'UserProfile'); describe('', () => { const coreStart = coreMock.createStart(); + // @ts-ignore Capabilities are marked as readonly without a way of overriding. + coreStart.application.capabilities = { + management: { + security: { + users: true, + }, + }, + }; const theme$ = themeServiceMock.createTheme$(); let history = scopedHistoryMock.create(); const authc = securityMock.createSetup().authc; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 65744ea9daab0..3cd9b659637c9 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -11,7 +11,7 @@ import useAsync from 'react-use/lib/useAsync'; import type { CoreStart } from 'src/core/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import type { UserAvatar, UserData } from '../../common'; +import type { UserData } from '../../common'; import { UserProfileAPIClient } from '../account_management/user_profile/user_profile_api_client'; import type { AuthenticationServiceSetup } from '../authentication'; @@ -30,7 +30,7 @@ export function useCurrentUser() { return useAsync(authc.getCurrentUser, [authc]); } -export function useUserProfile(dataPath = 'avatar') { +export function useUserProfile(dataPath?: string) { const { services } = useKibana(); return useAsync(() => new UserProfileAPIClient(services.http).get(dataPath), [services.http]); } From a265b00d025de3b9fd2a53ab98f5595e57f62746 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 29 Mar 2022 10:04:33 +0100 Subject: [PATCH 28/47] . --- .../security/public/nav_control/nav_control_component.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 856c34aa2b8bf..f606d63e67115 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -21,6 +21,7 @@ import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import type { UserAvatar as IUserAvatar } from '../../common'; import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import { UserAvatar } from '../account_management/user_profile'; import { useUserProfile } from '../components/use_current_user'; @@ -47,7 +48,7 @@ export const SecurityNavControl: FunctionComponent = ({ const userMenuLinks = useObservable(userMenuLinks$, []); const [isOpen, setIsOpen] = useState(false); - const userProfile = useUserProfile(); + const userProfile = useUserProfile<{ avatar: IUserAvatar }>('avatar'); const displayName = userProfile.value ? getUserDisplayName(userProfile.value.user) : ''; From 167a3d5b0bb9a42ff1612dfbdcff79935276c24d Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 29 Mar 2022 11:20:24 +0100 Subject: [PATCH 29/47] . --- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 2 files changed, 2 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c35b5dbe66678..e13bfd5b3edc5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -22062,7 +22062,6 @@ "xpack.security.account.changePasswordTitle": "パスワード", "xpack.security.account.currentPasswordRequired": "現在のパスワードが必要です。", "xpack.security.account.noEmailMessage": "メールアドレスがありません", - "xpack.security.account.pageTitle": "{strongUsername}の設定", "xpack.security.account.passwordLengthDescription": "パスワードが短すぎます。", "xpack.security.account.passwordsDoNotMatch": "パスワードが一致していません。", "xpack.security.account.usernameGroupDescription": "この情報は変更できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f706762740ad8..8878a78e3cb24 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22091,7 +22091,6 @@ "xpack.security.account.changePasswordTitle": "密码", "xpack.security.account.currentPasswordRequired": "当前密码必填。", "xpack.security.account.noEmailMessage": "没有电子邮件地址", - "xpack.security.account.pageTitle": "{strongUsername} 的设置", "xpack.security.account.passwordLengthDescription": "密码过短。", "xpack.security.account.passwordsDoNotMatch": "密码不匹配。", "xpack.security.account.usernameGroupDescription": "不能更改此信息。", From 9043774f9b730128accf4e73d86b8fc0cb84a062 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 29 Mar 2022 14:06:26 +0100 Subject: [PATCH 30/47] . --- packages/kbn-optimizer/limits.yml | 2 +- x-pack/plugins/security/public/account_management/index.ts | 2 ++ .../security/public/account_management/user_profile/index.ts | 1 + x-pack/plugins/security/public/components/use_current_user.ts | 2 +- .../security/public/nav_control/nav_control_component.tsx | 3 ++- x-pack/plugins/security/server/routes/user_profile/get.ts | 3 +++ x-pack/plugins/security/server/routes/user_profile/update.ts | 3 +++ 7 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index afe7fcd9ddc86..b8bb3bc598140 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -50,7 +50,7 @@ pageLoadAssetSize: savedObjectsTagging: 59482 savedObjectsTaggingOss: 20590 searchprofiler: 67080 - security: 95864 + security: 100000 snapshotRestore: 79032 spaces: 57868 telemetry: 51957 diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index bfba213c632d0..a05537136602a 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,3 +6,5 @@ */ export { accountManagementApp } from './account_management_app'; +export type { UserAvatarProps } from './user_profile'; +export { UserProfileAPIClient, UserAvatar } from './user_profile'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/index.ts b/x-pack/plugins/security/public/account_management/user_profile/index.ts index 1fd578a929e90..f8a8f953aa833 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/index.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -10,3 +10,4 @@ export { UserAvatar } from './user_avatar'; export type { UserProfileProps, UserProfileFormValues } from './user_profile'; export type { UserAvatarProps } from './user_avatar'; +export { UserProfileAPIClient } from './user_profile_api_client'; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 3cd9b659637c9..223a69d51e8ff 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -12,7 +12,7 @@ import type { CoreStart } from 'src/core/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { UserData } from '../../common'; -import { UserProfileAPIClient } from '../account_management/user_profile/user_profile_api_client'; +import { UserProfileAPIClient } from '../account_management'; import type { AuthenticationServiceSetup } from '../authentication'; export interface AuthenticationProviderProps { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index f606d63e67115..d462ae8ba6025 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -23,7 +23,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { UserAvatar as IUserAvatar } from '../../common'; import { getUserDisplayName, isUserAnonymous } from '../../common/model'; -import { UserAvatar } from '../account_management/user_profile'; +import { UserAvatar } from '../account_management'; import { useUserProfile } from '../components/use_current_user'; export interface UserMenuLink { @@ -62,6 +62,7 @@ export const SecurityNavControl: FunctionComponent = ({ })} onClick={() => setIsOpen((value) => (userProfile.value ? !value : false))} data-test-subj="userMenuButton" + style={{ lineHeight: 'normal' }} > {userProfile.value ? ( { const session = await getSession().get(request); if (!session) { + logger.warn('User profile requested without valid session.'); return response.unauthorized(); } if (!session.userProfileId) { + logger.warn(`User profile missing from current session. (sid: ${session.sid})`); return response.notFound(); } diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 8c035b09b082c..e54d49ada8dbd 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -15,6 +15,7 @@ export function defineUpdateUserProfileDataRoute({ router, getSession, getUserProfileService, + logger, }: RouteDefinitionParams) { router.post( { @@ -26,9 +27,11 @@ export function defineUpdateUserProfileDataRoute({ createLicensedRouteHandler(async (context, request, response) => { const session = await getSession().get(request); if (!session) { + logger.warn('User profile requested without valid session.'); return response.unauthorized(); } if (!session.userProfileId) { + logger.warn(`User profile missing from current session. (sid: ${session.sid})`); return response.notFound(); } From 906b4c0bd1f8ce2102256707811b5bd3517048ed Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 20 Apr 2022 10:31:51 +0100 Subject: [PATCH 31/47] . --- .../account_management_app.test.tsx | 6 +++--- .../account_management/account_management_app.tsx | 13 +++++-------- .../user_profile/user_profile.test.tsx | 4 ++-- .../user_profile/user_profile.tsx | 4 ++-- .../user_profile/user_profile_api_client.test.ts | 2 +- .../user_profile/user_profile_api_client.ts | 2 +- .../nav_control/nav_control_component.test.tsx | 2 +- 7 files changed, 15 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx index 5b282bb6e435d..ce6218ef23bfc 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -8,9 +8,9 @@ import { act } from '@testing-library/react'; import { noop } from 'lodash'; -import type { AppUnmount } from 'src/core/public'; -import { AppNavLinkStatus } from 'src/core/public'; -import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; +import type { AppUnmount } from '@kbn/core/public'; +import { AppNavLinkStatus } from '@kbn/core/public'; +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; import { securityMock } from '../mocks'; import { accountManagementApp } from './account_management_app'; diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx index 9e9f305146d74..68f5a0bdbb298 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -12,21 +12,18 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Router } from 'react-router-dom'; import type { Observable } from 'rxjs'; -import { i18n } from '@kbn/i18n'; -import { I18nProvider } from '@kbn/i18n-react'; import type { ApplicationSetup, AppMountParameters, CoreStart, CoreTheme, StartServicesAccessor, -} from 'src/core/public'; +} from '@kbn/core/public'; +import { AppNavLinkStatus } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { I18nProvider } from '@kbn/i18n-react'; +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import { AppNavLinkStatus } from '../../../../../src/core/public'; -import { - KibanaContextProvider, - KibanaThemeProvider, -} from '../../../../../src/plugins/kibana_react/public'; import type { AuthenticationServiceSetup } from '../authentication'; import type { BreadcrumbsChangeHandler } from '../components/breadcrumb'; import { BreadcrumbsProvider } from '../components/breadcrumb'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 65b778bcf554e..1e9b08f970cf6 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -9,9 +9,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import type { FunctionComponent } from 'react'; import React from 'react'; -import { coreMock, scopedHistoryMock, themeServiceMock } from 'src/core/public/mocks'; +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; -import type { UserData } from '../../../common/'; +import type { UserData } from '../../../common'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { Providers } from '../account_management_app'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 98f3a5a62bd05..a18ee144739d1 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -30,11 +30,11 @@ import type { FunctionComponent } from 'react'; import React, { useRef, useState } from 'react'; import useUpdateEffect from 'react-use/lib/useUpdateEffect'; +import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { CoreStart } from 'src/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; import { canUserChangeDetails, diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts index c262581bf9656..358aaf126384e 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { coreMock } from 'src/core/public/mocks'; +import { coreMock } from '@kbn/core/public/mocks'; import { UserProfileAPIClient } from './user_profile_api_client'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts index ab936b639df7c..7d8a023e411ad 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { HttpStart } from 'src/core/public'; +import type { HttpStart } from '@kbn/core/public'; import type { AuthenticatedUserProfile, UserData } from '../../../common'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 8da929130869f..4869adab7be0c 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -13,7 +13,7 @@ import { act } from 'react-dom/test-utils'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject } from 'rxjs'; -import { coreMock, themeServiceMock } from 'src/core/public/mocks'; +import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { userProfileMock } from '../../common/model/user_profile.mock'; From e433179ecbad70554f88bab4c4e0b76dceffbcd6 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Thu, 21 Apr 2022 10:28:25 +0100 Subject: [PATCH 32/47] . --- renovate.json | 1 + .../common/model/authenticated_user.test.ts | 2 +- .../security/common/model/user_profile.ts | 2 +- .../user_profile/user_avatar.tsx | 2 +- .../user_profile/user_profile.tsx | 619 ++++++++++-------- .../edit_user/change_password_flyout.tsx | 2 +- .../nav_control_component.test.tsx | 10 + .../nav_control/nav_control_component.tsx | 2 +- .../server/routes/user_profile/get.ts | 7 +- .../server/routes/user_profile/index.ts | 2 +- .../server/routes/user_profile/update.ts | 4 +- .../user_profile/user_profile_service.test.ts | 2 +- 12 files changed, 351 insertions(+), 304 deletions(-) diff --git a/renovate.json b/renovate.json index 4b9418311ced7..2580249b50f1e 100644 --- a/renovate.json +++ b/renovate.json @@ -116,6 +116,7 @@ "matchPackageNames": [ "broadcast-channel", "node-forge", + "formik", "@types/node-forge", "require-in-the-middle", "tough-cookie", diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index 74bff7a4d8958..e8c386d18c488 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { applicationServiceMock } from 'src/core/public/mocks'; +import { applicationServiceMock } from '@kbn/core/public/mocks'; import type { AuthenticatedUser } from './authenticated_user'; import { canUserChangeDetails, canUserChangePassword, isUserAnonymous } from './authenticated_user'; diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index a606d1ea0faa3..fc5489ff0a173 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -7,7 +7,7 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; -import type { User } from '../'; +import type { User } from '..'; import type { AuthenticatedUser } from './authenticated_user'; import { getUserDisplayName } from './user'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx index b054ba4207592..46cd1754fe17b 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -44,7 +44,7 @@ export const UserAvatar: FunctionComponent = ({ user, avatar, . const displayName = getUserDisplayName(user); if (avatar && avatar.imageUrl) { - return ; + return ; } return ( diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index a18ee144739d1..3bb680997d14b 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -8,21 +8,19 @@ import { EuiButton, EuiButtonEmpty, - EuiCallOut, + EuiButtonGroup, EuiColorPicker, EuiDescribedFormGroup, - EuiFieldText, + EuiDescriptionList, EuiFilePicker, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, - EuiKeyPadMenu, - EuiKeyPadMenuItem, + EuiIconTip, EuiPageTemplate, EuiSpacer, - EuiSplitPanel, - EuiToolTip, + EuiText, useGeneratedHtmlId, } from '@elastic/eui'; import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; @@ -85,12 +83,68 @@ export const UserProfile: FunctionComponent = ({ user, data }) const formik = useUserProfileForm({ user, data }); const formChanges = useFormChanges(); const titleId = useGeneratedHtmlId(); - const [showDeleteFlyout, setShowDeleteFlyout] = useState(false); + const [showChangePasswordFlyout, setShowChangePasswordFlyout] = useState(false); const isReservedUser = isUserReserved(user); const canChangePassword = canUserChangePassword(user); const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + const rightSideItems = [ + { + title: ( + + ), + description: user.username, + helpText: ( + + ), + }, + ]; + + if (!canChangeDetails) { + if (user.full_name) { + rightSideItems.push({ + title: ( + + ), + description: user.full_name, + helpText: ( + + ), + }); + } + + if (user.email) { + rightSideItems.push({ + title: ( + + ), + description: user.email, + helpText: ( + + ), + }); + } + } + return ( @@ -99,10 +153,11 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage: 'Profile', })} > - {showDeleteFlyout ? ( + {showChangePasswordFlyout ? ( setShowDeleteFlyout(false)} + onCancel={() => setShowChangePasswordFlyout(false)} + onSuccess={() => setShowChangePasswordFlyout(false)} /> ) : null} @@ -115,134 +170,81 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> ), pageTitleProps: { id: titleId }, - rightSideItems: [ - canChangePassword ? ( - setShowDeleteFlyout(true)} iconType="lock" fill> - - - ) : ( - - } - > - - - - - ), - ], + rightSideItems: rightSideItems.reverse().map((item) => ( + + + {item.title} + + + + + + ), + description: item.description, + }, + ]} + compressed + /> + )), }} bottomBar={formChanges.count > 0 ? : null} restrictWidth={900} > - {isReservedUser ? ( - <> - + {canChangeDetails ? ( + +

+ +

} - iconType="lock" - /> - - - ) : null} - - - + description={ - - } - description={ - - } - > - {isReservedUser ? ( - + + + + } + labelAppend={} fullWidth - isDisabled > - - - ) : ( - <> - - - - } - helpText={ - !canChangeDetails ? ( - - ) : null - } - labelAppend={canChangeDetails ? : null} - isDisabled={!canChangeDetails} - fullWidth - > - - + + - - - - } - helpText={ - !canChangeDetails ? ( - - ) : null - } - labelAppend={canChangeDetails ? : null} - isDisabled={!canChangeDetails} - fullWidth - > - - - - )} - + + + + } + labelAppend={} + fullWidth + > + + +
+ ) : null} = ({ user, data }) /> } > - {!isReservedUser && ( - <> - - - - - ), - }} - > - - } - onChange={() => formik.setFieldValue('avatarType', 'initials')} - isSelected={formik.values.avatarType === 'initials'} - isDisabled={isReservedUser} - > - - - - } - onChange={() => formik.setFieldValue('avatarType', 'image')} - isSelected={formik.values.avatarType === 'image'} - isDisabled={isReservedUser} - > - - - - - - - )} - - - + + {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( ) : ( @@ -332,145 +283,231 @@ export const UserProfile: FunctionComponent = ({ user, data }) size="xl" /> )} - - - {formik.values.avatarType === 'image' ? ( + + + + + + } + fullWidth + > + + ), + iconType: 'lettering', + }, + { + id: 'image', + label: ( + + ), + iconType: 'image', + }, + ]} + onChange={(id: string) => formik.setFieldValue('avatarType', id)} + isFullWidth + /> + + + + + + {formik.values.avatarType === 'image' ? ( + + + + } + fullWidth + > + + } + onChange={createImageHandler((imageUrl) => { + formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); + })} + validate={{ + required: i18n.translate( + 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', + { + defaultMessage: 'Upload an image.', + } + ), + }} + accept={IMAGE_FILE_TYPES.join(',')} + display="default" + fullWidth + /> + + ) : ( + + + } - isDisabled={isReservedUser} fullWidth > - } - onChange={createImageHandler((imageUrl) => { - formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); - })} + name="data.avatar.initials" validate={{ required: i18n.translate( - 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', + 'xpack.security.accountManagement.userProfile.initialsRequiredError', { - defaultMessage: 'Upload an image.', + defaultMessage: 'Enter initials.', } ), + maxLength: { + value: 2, + message: i18n.translate( + 'xpack.security.accountManagement.userProfile.initialsMaxLengthError', + { + defaultMessage: 'Enter no more than 2 characters.', + } + ), + }, }} - accept={IMAGE_FILE_TYPES.join(',')} - display="default" fullWidth /> - ) : ( - <> - + + + + + + } + labelAppend={ + !isReservedUser ? ( + + formik.setFieldValue('data.avatar.color', getRandomColor()) + } + size="xs" + flush="right" + style={{ height: 18 }} + > - - } - isDisabled={isReservedUser} - fullWidth - > - + ) : null + } + fullWidth + > + - - - - - - } - labelAppend={ - !isReservedUser ? ( - - formik.setFieldValue('data.avatar.color', getRandomColor()) - } - size="xs" - flush="right" - style={{ height: 18 }} - > - - - ) : null - } - isDisabled={isReservedUser} + }, + }} + onChange={(value: string) => { + formik.setFieldValue('data.avatar.color', value); + }} fullWidth - > - { - formik.setFieldValue('data.avatar.color', value); - }} - fullWidth - /> - - - )} - - + /> + + + + )} + + {canChangePassword ? ( + + + + } + description={ + + } + > + + } + fullWidth + > + setShowChangePasswordFlyout(true)} iconType="lock"> + + + + + ) : null} diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx index d8358a6fbba20..56edb43406dd7 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx @@ -118,7 +118,7 @@ export const ChangePasswordFlyout: FunctionComponent ); onSuccess?.(); } catch (error) { - if ((error as any).body?.message === 'security_exception') { + if ((error as any).body?.statusCode === 403) { form.setError( 'current_password', i18n.translate( diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index 4869adab7be0c..bdcc24657d70d 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -69,6 +69,11 @@ describe('SecurityNavControl', () => { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > = ({ if (!isAnonymous) { const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); - const profileMenuItem = { + const profileMenuItem: EuiContextMenuPanelItemDescriptor = { name: ( { const session = await getSession().get(request); if (!session) { - logger.warn('User profile requested without valid session.'); - return response.unauthorized(); + return response.notFound(); } if (!session.userProfileId) { logger.warn(`User profile missing from current session. (sid: ${session.sid})`); diff --git a/x-pack/plugins/security/server/routes/user_profile/index.ts b/x-pack/plugins/security/server/routes/user_profile/index.ts index 293ec0e75cf30..6d526d2ce6a75 100644 --- a/x-pack/plugins/security/server/routes/user_profile/index.ts +++ b/x-pack/plugins/security/server/routes/user_profile/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { RouteDefinitionParams } from '../index'; +import type { RouteDefinitionParams } from '..'; import { defineGetUserProfileRoute } from './get'; import { defineUpdateUserProfileDataRoute } from './update'; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index e54d49ada8dbd..483713ae31a22 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; -import type { RouteDefinitionParams } from '../'; +import type { RouteDefinitionParams } from '..'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; @@ -28,7 +28,7 @@ export function defineUpdateUserProfileDataRoute({ const session = await getSession().get(request); if (!session) { logger.warn('User profile requested without valid session.'); - return response.unauthorized(); + return response.notFound(); } if (!session.userProfileId) { logger.warn(`User profile missing from current session. (sid: ${session.sid})`); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index ce87ea46acb89..f861024e1ba09 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -7,7 +7,7 @@ import { errors } from '@elastic/elasticsearch'; -import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; import { userProfileMock } from '../../common/model/user_profile.mock'; import { securityMock } from '../mocks'; From d6851c7049f0e2336e2a7bd3691f8db48ba0806d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 21 Apr 2022 10:19:20 +0000 Subject: [PATCH 33/47] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- x-pack/plugins/security/common/model/authenticated_user.ts | 2 +- x-pack/plugins/security/public/components/use_current_user.ts | 4 ++-- .../security/server/user_profile/user_profile_service.mock.ts | 2 +- .../security/server/user_profile/user_profile_service.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index c974da29b5646..94ea8fd49c240 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Capabilities } from 'src/core/types'; +import type { Capabilities } from '@kbn/core/types'; import type { AuthenticationProvider } from './authentication_provider'; import type { User } from './user'; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 223a69d51e8ff..7f9e02e1ed151 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -8,9 +8,9 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; -import type { CoreStart } from 'src/core/public'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { UserData } from '../../common'; import { UserProfileAPIClient } from '../account_management'; import type { AuthenticationServiceSetup } from '../authentication'; diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts index 4ccf0e91a3d80..e6fd72c15f110 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.mock.ts @@ -5,8 +5,8 @@ * 2.0. */ +import type { UserProfileServiceStart } from '.'; import { userProfileMock } from '../../common/model/user_profile.mock'; -import type { UserProfileServiceStart } from '../user_profile'; export const userProfileServiceMock = { createStart: (): jest.Mocked => ({ diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 08d2094d1b8f8..02ce1239643b7 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IClusterClient, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from '@kbn/core/server'; import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; import { getDetailedErrorMessage } from '../errors'; From cf2d7b7ccc7177729627f16b52157a1b01be0145 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Thu, 21 Apr 2022 18:02:48 +0100 Subject: [PATCH 34/47] . --- .../common/model/authenticated_user.ts | 2 +- .../user_profile/user_profile.tsx | 5 +- .../public/components/use_current_user.ts | 4 +- .../nav_control_component.test.tsx | 85 ++++++++----------- .../nav_control/nav_control_component.tsx | 15 ++-- .../nav_control/nav_control_service.test.ts | 44 +++++++--- .../nav_control/nav_control_service.tsx | 27 ++++-- x-pack/plugins/security/public/plugin.tsx | 2 +- .../user_profile/user_profile_service.ts | 2 +- .../translations/translations/fr-FR.json | 1 - 10 files changed, 104 insertions(+), 83 deletions(-) diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index c974da29b5646..94ea8fd49c240 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { Capabilities } from 'src/core/types'; +import type { Capabilities } from '@kbn/core/types'; import type { AuthenticationProvider } from './authentication_provider'; import type { User } from './user'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 3bb680997d14b..9fd240c06209c 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -104,6 +104,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Username can't be changed once created." /> ), + testSubj: 'username', }, ]; @@ -123,6 +124,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Contact an administrator to change your full name." /> ), + testSubj: 'full_name', }); } @@ -141,6 +143,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage="Contact an administrator to change your email address." /> ), + testSubj: 'email', }); } } @@ -185,7 +188,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) ), - description: item.description, + description: {item.description}, }, ]} compressed diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 223a69d51e8ff..7f9e02e1ed151 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -8,9 +8,9 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; -import type { CoreStart } from 'src/core/public'; +import type { CoreStart } from '@kbn/core/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { UserData } from '../../common'; import { UserProfileAPIClient } from '../account_management'; import type { AuthenticationServiceSetup } from '../authentication'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index bdcc24657d70d..24d4594091d5f 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -7,37 +7,27 @@ import { EuiContextMenu } from '@elastic/eui'; import { shallow } from 'enzyme'; -import type { FunctionComponent, ReactElement } from 'react'; +import type { ReactElement } from 'react'; import React from 'react'; import { act } from 'react-dom/test-utils'; import useObservable from 'react-use/lib/useObservable'; import { BehaviorSubject } from 'rxjs'; -import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; - import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; import { userProfileMock } from '../../common/model/user_profile.mock'; import * as UseCurrentUserImports from '../components/use_current_user'; import { SecurityNavControl } from './nav_control_component'; -import { Providers } from './nav_control_service'; jest.mock('../components/use_current_user'); jest.mock('react-use/lib/useObservable'); const useObservableMock = useObservable as jest.Mock; const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); +const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser'); const userProfile = userProfileMock.create(); -const coreStart = coreMock.createStart(); -const theme$ = themeServiceMock.createTheme$(); const userMenuLinks$ = new BehaviorSubject([]); -const wrappingComponent: FunctionComponent = ({ children }) => ( - - {children} - -); - describe('SecurityNavControl', () => { beforeEach(() => { useUserProfileMock.mockReset(); @@ -46,6 +36,12 @@ describe('SecurityNavControl', () => { value: userProfile, }); + useCurrentUserMock.mockReset(); + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser(), + }); + useObservableMock.mockReset(); useObservableMock.mockImplementation( (observable: BehaviorSubject, initialValue = {}) => observable.value ?? initialValue @@ -54,13 +50,11 @@ describe('SecurityNavControl', () => { it('should render an avatar when user profile has loaded', async () => { const wrapper = shallow( - , - { - wrappingComponent, - } + ); expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); expect(wrapper.prop('button')).toMatchInlineSnapshot(` { size="s" user={ Object { - "active": true, "authentication_provider": Object { "name": "basic1", "type": "basic", @@ -100,8 +93,10 @@ describe('SecurityNavControl', () => { "metadata": Object { "_reserved": false, }, - "roles": Array [], - "username": "some-username", + "roles": Array [ + "user-role", + ], + "username": "user", } } /> @@ -113,15 +108,16 @@ describe('SecurityNavControl', () => { useUserProfileMock.mockReturnValue({ loading: true, }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); const wrapper = shallow( - , - { - wrappingComponent, - } + ); expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); expect(wrapper.prop('button')).toMatchInlineSnapshot(` { it('should open popover when avatar is clicked', async () => { const wrapper = shallow( - , - { - wrappingComponent, - } + ); act(() => { @@ -163,12 +156,12 @@ describe('SecurityNavControl', () => { useUserProfileMock.mockReturnValue({ loading: true, }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); const wrapper = shallow( - , - { - wrappingComponent, - } + ); act(() => { @@ -191,10 +184,7 @@ describe('SecurityNavControl', () => { { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, ]) } - />, - { - wrappingComponent, - } + /> ); expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` @@ -284,10 +274,7 @@ describe('SecurityNavControl', () => { }, ]) } - />, - { - wrappingComponent, - } + /> ); expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` @@ -362,21 +349,19 @@ describe('SecurityNavControl', () => { it('should render anonymous user', async () => { useUserProfileMock.mockReturnValue({ loading: false, - value: userProfileMock.create({ - user: { - ...mockAuthenticatedUser({ - authentication_provider: { type: 'anonymous', name: 'does no matter' }, - }), - active: true, - }, + value: undefined, + error: new Error('404'), + }); + + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, }), }); const wrapper = shallow( - , - { - wrappingComponent, - } + ); expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 497dee2c547f1..399bd7162d3f8 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -24,7 +24,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { UserAvatar as IUserAvatar } from '../../common'; import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import { UserAvatar } from '../account_management'; -import { useUserProfile } from '../components/use_current_user'; +import { useCurrentUser, useUserProfile } from '../components/use_current_user'; export interface UserMenuLink { label: string; @@ -49,8 +49,9 @@ export const SecurityNavControl: FunctionComponent = ({ const [isOpen, setIsOpen] = useState(false); const userProfile = useUserProfile<{ avatar: IUserAvatar }>('avatar'); + const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well - const displayName = userProfile.value ? getUserDisplayName(userProfile.value.user) : ''; + const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : ''; const button = ( = ({ aria-label={i18n.translate('xpack.security.navControlComponent.accountMenuAriaLabel', { defaultMessage: 'Account menu', })} - onClick={() => setIsOpen((value) => (userProfile.value ? !value : false))} + onClick={() => setIsOpen((value) => (currentUser.value ? !value : false))} data-test-subj="userMenuButton" style={{ lineHeight: 'normal' }} > - {userProfile.value ? ( + {currentUser.value && userProfile.value ? ( + ) : currentUser.value && userProfile.error ? ( + ) : ( )} ); - const isAnonymous = userProfile.value ? isUserAnonymous(userProfile.value.user) : false; + const isAnonymous = currentUser.value ? isUserAnonymous(currentUser.value) : false; const items: EuiContextMenuPanelItemDescriptor[] = []; if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index 85329f2af2e2a..a7e22f79d0be0 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -12,15 +12,21 @@ import type { ILicense } from '@kbn/licensing-plugin/public'; import { nextTick } from '@kbn/test-jest-helpers'; import { SecurityLicenseService } from '../../common/licensing'; +import { authenticationMock } from '../authentication/index.mock'; import * as UseCurrentUserImports from '../components/use_current_user'; import { SecurityNavControlService } from './nav_control_service'; + const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); +const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser'); -useUserProfileMock.mockReset(); useUserProfileMock.mockReturnValue({ loading: true, }); +useCurrentUserMock.mockReturnValue({ + loading: true, +}); + const validLicense = { isAvailable: true, getFeature: (feature) => { @@ -34,6 +40,8 @@ const validLicense = { hasAtLeast: (...candidates) => true, } as ILicense; +const authc = authenticationMock.createStart(); + describe('SecurityNavControlService', () => { it('can render and cleanup the control via the mount() function', async () => { const license$ = new BehaviorSubject(validLicense); @@ -47,7 +55,7 @@ describe('SecurityNavControlService', () => { const coreStart = coreMock.createStart(); coreStart.chrome.navControls.registerRight = jest.fn(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); const [{ mount }] = coreStart.chrome.navControls.registerRight.mock.calls[0]; @@ -111,7 +119,7 @@ describe('SecurityNavControlService', () => { }); const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); @@ -131,7 +139,7 @@ describe('SecurityNavControlService', () => { const coreStart = coreMock.createStart(); coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); }); @@ -146,7 +154,7 @@ describe('SecurityNavControlService', () => { }); const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -167,13 +175,13 @@ describe('SecurityNavControlService', () => { }); const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); navControlService.stop(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(2); }); @@ -191,14 +199,17 @@ describe('SecurityNavControlService', () => { it('should return functions to register and retrieve user menu links', () => { const coreStart = coreMock.createStart(); - const navControlServiceStart = navControlService.start({ core: coreStart }); + const navControlServiceStart = navControlService.start({ core: coreStart, authc }); expect(navControlServiceStart).toHaveProperty('getUserMenuLinks$'); expect(navControlServiceStart).toHaveProperty('addUserMenuLinks'); }); it('should register custom user menu links to be displayed in the nav controls', (done) => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -225,7 +236,10 @@ describe('SecurityNavControlService', () => { it('should retrieve user menu links sorted by order', (done) => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -292,7 +306,10 @@ describe('SecurityNavControlService', () => { it('should allow adding a custom profile link', () => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); addUserMenuLinks([ @@ -312,7 +329,10 @@ describe('SecurityNavControlService', () => { it('should not allow adding more than one custom profile link', () => { const coreStart = coreMock.createStart(); - const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ + core: coreStart, + authc, + }); const userMenuLinks$ = getUserMenuLinks$(); expect(() => { diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index d0dd0ac865035..29b4e4ccff080 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -18,6 +18,8 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { SecurityLicense } from '../../common/licensing'; +import type { AuthenticationServiceSetup } from '../authentication'; +import { AuthenticationProvider } from '../components/use_current_user'; import type { UserMenuLink } from './nav_control_component'; import { SecurityNavControl } from './nav_control_component'; @@ -28,6 +30,7 @@ interface SetupDeps { interface StartDeps { core: CoreStart; + authc: AuthenticationServiceSetup; } export interface SecurityNavControlServiceStart { @@ -58,7 +61,7 @@ export class SecurityNavControlService { this.logoutUrl = logoutUrl; } - public start({ core }: StartDeps): SecurityNavControlServiceStart { + public start({ core, authc }: StartDeps): SecurityNavControlServiceStart { this.securityFeaturesSubscription = this.securityLicense.features$.subscribe( ({ showLinks }) => { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); @@ -66,7 +69,7 @@ export class SecurityNavControlService { const shouldRegisterNavControl = !isAnonymousPath && showLinks && !this.navControlRegistered; if (shouldRegisterNavControl) { - this.registerSecurityNavControl(core); + this.registerSecurityNavControl(core, authc); } } ); @@ -108,13 +111,13 @@ export class SecurityNavControlService { this.stop$.next(); } - private registerSecurityNavControl(core: CoreStart) { + private registerSecurityNavControl(core: CoreStart, authc: AuthenticationServiceSetup) { const { theme$ } = core.theme; core.chrome.navControls.registerRight({ order: 2000, mount: (element: HTMLElement) => { ReactDOM.render( - + ; } -export const Providers: FunctionComponent = ({ services, theme$, children }) => ( +export const Providers: FunctionComponent = ({ + authc, + services, + theme$, + children, +}) => ( - - {children} - + + + {children} + + ); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 217815f98d2ce..a9867c0324fbd 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -166,7 +166,7 @@ export class SecurityPlugin return { uiApi: getUiApi({ core }), - navControlService: this.navControlService.start({ core }), + navControlService: this.navControlService.start({ core, authc: this.authc }), authc: this.authc as AuthenticationServiceStart, }; } diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 08d2094d1b8f8..02ce1239643b7 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { IClusterClient, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from '@kbn/core/server'; import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; import { getDetailedErrorMessage } from '../errors'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 901f2c3333022..8e33b48010435 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -19032,7 +19032,6 @@ "xpack.security.account.changePasswordTitle": "Mot de passe", "xpack.security.account.currentPasswordRequired": "Le mot de passe actuel est requis.", "xpack.security.account.noEmailMessage": "aucune adresse e-mail", - "xpack.security.account.pageTitle": "Paramètres de {strongUsername}", "xpack.security.account.passwordLengthDescription": "Le mot de passe est trop court.", "xpack.security.account.passwordsDoNotMatch": "Les mots de passe ne correspondent pas.", "xpack.security.account.usernameGroupDescription": "Vous ne pouvez pas modifier ces informations.", From ee80e5507e1aeb1a07b1fe830151d71b132479c1 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Thu, 21 Apr 2022 21:19:03 +0100 Subject: [PATCH 35/47] . --- x-pack/test/functional/apps/security/user_email.ts | 6 +++--- .../test/functional/page_objects/account_settings_page.ts | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/x-pack/test/functional/apps/security/user_email.ts b/x-pack/test/functional/apps/security/user_email.ts index 65bf111ceedbf..b59f1ecee7d76 100644 --- a/x-pack/test/functional/apps/security/user_email.ts +++ b/x-pack/test/functional/apps/security/user_email.ts @@ -43,18 +43,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('login as new user and verify email', async function () { await PageObjects.security.login('newuser', 'changeme'); - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); }); it('click changepassword link, change the password and re-login', async function () { - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); await PageObjects.accountSetting.changePassword('changeme', 'mechange'); await PageObjects.security.forceLogout(); }); it('login as new user with changed password', async function () { await PageObjects.security.login('newuser', 'mechange'); - await PageObjects.accountSetting.verifyAccountSettings('newuser@myEmail.com', 'newuser'); + await PageObjects.accountSetting.verifyAccountSettings('newuser'); }); after(async function () { diff --git a/x-pack/test/functional/page_objects/account_settings_page.ts b/x-pack/test/functional/page_objects/account_settings_page.ts index 5bf5be9030b75..d92a7bf1b1f0a 100644 --- a/x-pack/test/functional/page_objects/account_settings_page.ts +++ b/x-pack/test/functional/page_objects/account_settings_page.ts @@ -12,16 +12,13 @@ export class AccountSettingsPageObject extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly userMenu = this.ctx.getService('userMenu'); - async verifyAccountSettings(expectedEmail: string, expectedUserName: string) { + async verifyAccountSettings(expectedUserName: string) { await this.userMenu.clickProvileLink(); const usernameField = await this.testSubjects.find('username'); const userName = await usernameField.getVisibleText(); expect(userName).to.be(expectedUserName); - const emailIdField = await this.testSubjects.find('email'); - const emailField = await emailIdField.getVisibleText(); - expect(emailField).to.be(expectedEmail); await this.userMenu.closeMenu(); } From b0f70159f21a5d928ffea8b9811e5dfbdb0f67ed Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 3 May 2022 10:48:24 +0100 Subject: [PATCH 36/47] . --- .../public/account_management/account_management_page.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 27f9fe4c264b8..287aefcdf307e 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { EuiEmptyPrompt } from '@elastic/eui'; import type { FunctionComponent } from 'react'; import React from 'react'; @@ -23,6 +24,11 @@ export const AccountManagementPage: FunctionComponent = () => { const currentUser = useCurrentUser(); const userProfile = useUserProfile>('avatar'); + const error = currentUser.error || userProfile.error; + if (error) { + return {error.message}} />; + } + if (!currentUser.value || !userProfile.value) { return null; } From 8afade3fb8e56da4c2d84662b7aee6f02d9ee0b2 Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Tue, 3 May 2022 16:29:31 +0100 Subject: [PATCH 37/47] . --- .../user_profile/user_profile.tsx | 6 ++++- .../page_objects/account_settings_page.ts | 25 +++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 9fd240c06209c..d86b96e0e920a 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -502,7 +502,11 @@ export const UserProfile: FunctionComponent = ({ user, data }) } fullWidth > - setShowChangePasswordFlyout(true)} iconType="lock"> + setShowChangePasswordFlyout(true)} + iconType="lock" + data-test-subj="openChangePasswordFlyout" + > Date: Wed, 4 May 2022 11:03:32 +0100 Subject: [PATCH 38/47] . --- .../tests/session_idle/cleanup.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index c5639c26b8af4..4bdaa02846a07 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -121,12 +121,9 @@ export default function ({ getService }: FtrProviderContext) { it('should properly clean up session expired because of idle timeout when providers override global session config', async function () { this.timeout(100000); - const [samlDisableSessionCookie, samlOverrideSessionCookie, samlFallbackSessionCookie] = - await Promise.all([ - loginWithSAML('saml_disable'), - loginWithSAML('saml_override'), - loginWithSAML('saml_fallback'), - ]); + const samlDisableSessionCookie = await loginWithSAML('saml_disable'); + const samlOverrideSessionCookie = await loginWithSAML('saml_override'); + const samlFallbackSessionCookie = await loginWithSAML('saml_fallback'); const response = await supertest .post('/internal/security/login') From eb414b90569a382ccfe8feeeafcb5eb6022ad70f Mon Sep 17 00:00:00 2001 From: Thom Heymann Date: Wed, 4 May 2022 21:44:24 +0100 Subject: [PATCH 39/47] . --- .../tests/session_lifespan/cleanup.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index f39e43856415b..2d3ed3473194b 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -112,12 +112,9 @@ export default function ({ getService }: FtrProviderContext) { it('should properly clean up session expired because of lifespan when providers override global session config', async function () { this.timeout(100000); - const [samlDisableSessionCookie, samlOverrideSessionCookie, samlFallbackSessionCookie] = - await Promise.all([ - loginWithSAML('saml_disable'), - loginWithSAML('saml_override'), - loginWithSAML('saml_fallback'), - ]); + const samlDisableSessionCookie = await loginWithSAML('saml_disable'); + const samlOverrideSessionCookie = await loginWithSAML('saml_override'); + const samlFallbackSessionCookie = await loginWithSAML('saml_fallback'); const response = await supertest .post('/internal/security/login') From a1b53ef861fd9749515127db0192c7d5ce21b6f2 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 17 May 2022 14:25:56 +0200 Subject: [PATCH 40/47] Review#1: handle UI inconsistencies, update avatar in the navigation bar when profile data is updated, etc. --- .../account_management_app.tsx | 32 ++++++--- .../account_management_page.test.tsx | 15 ++++- .../account_management_page.tsx | 13 ++-- .../user_profile/user_avatar.tsx | 17 ++--- .../user_profile/user_profile.test.tsx | 15 ++++- .../user_profile/user_profile.tsx | 65 +++++++++---------- .../user_profile_api_client.test.ts | 12 ++-- .../user_profile/user_profile_api_client.ts | 14 +++- .../public/components/api_clients_provider.ts | 23 +++++++ .../public/components/form_changes.tsx | 11 +--- .../security/public/components/index.ts | 14 ++++ .../public/components/use_current_user.ts | 11 ++-- .../management/users/edit_user/user_form.tsx | 2 +- .../nav_control/nav_control_component.tsx | 2 +- .../nav_control/nav_control_service.tsx | 20 ++++-- x-pack/plugins/security/public/plugin.tsx | 11 +++- 16 files changed, 178 insertions(+), 99 deletions(-) create mode 100644 x-pack/plugins/security/public/components/api_clients_provider.ts create mode 100644 x-pack/plugins/security/public/components/index.ts diff --git a/x-pack/plugins/security/public/account_management/account_management_app.tsx b/x-pack/plugins/security/public/account_management/account_management_app.tsx index 68f5a0bdbb298..104cc88591b4e 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -25,19 +25,21 @@ import { I18nProvider } from '@kbn/i18n-react'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import type { AuthenticationServiceSetup } from '../authentication'; +import type { ApiClients } from '../components'; +import { ApiClientsProvider, AuthenticationProvider } from '../components'; import type { BreadcrumbsChangeHandler } from '../components/breadcrumb'; import { BreadcrumbsProvider } from '../components/breadcrumb'; -import { AuthenticationProvider } from '../components/use_current_user'; interface CreateDeps { application: ApplicationSetup; authc: AuthenticationServiceSetup; + apiClients: ApiClients; getStartServices: StartServicesAccessor; } export const accountManagementApp = Object.freeze({ id: 'security_account', - create({ application, authc, getStartServices }: CreateDeps) { + create({ application, authc, getStartServices, apiClients }: CreateDeps) { application.register({ id: this.id, title: i18n.translate('xpack.security.account.breadcrumb', { @@ -52,7 +54,13 @@ export const accountManagementApp = Object.freeze({ ]); render( - + , element @@ -69,6 +77,7 @@ export interface ProvidersProps { theme$: Observable; history: History; authc: AuthenticationServiceSetup; + apiClients: ApiClients; onChange?: BreadcrumbsChangeHandler; } @@ -77,18 +86,21 @@ export const Providers: FunctionComponent = ({ theme$, history, authc, + apiClients, onChange, children, }) => ( - - - - {children} - - - + + + + + {children} + + + + ); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx index eef05881ce6a1..a75d7192089b4 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.test.tsx @@ -12,9 +12,11 @@ import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/ import type { UserData } from '../../common'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { UserAPIClient } from '../management'; import { securityMock } from '../mocks'; import { Providers } from './account_management_app'; import { AccountManagementPage } from './account_management_page'; +import { UserProfileAPIClient } from './user_profile'; import * as UserProfileImports from './user_profile/user_profile'; const UserProfileMock = jest.spyOn(UserProfileImports, 'UserProfile'); @@ -51,7 +53,16 @@ describe('', () => { coreStart.http.get.mockResolvedValue({ user, data }); const { findByRole } = render( - + ); @@ -60,7 +71,7 @@ describe('', () => { expect(UserProfileMock).toHaveBeenCalledWith({ user, data }, expect.anything()); expect(coreStart.chrome.setBreadcrumbs).toHaveBeenLastCalledWith([ - { href: '/security/account', text: 'full name' }, + { href: '/security/account', text: 'User settings' }, { href: undefined, text: 'Profile' }, ]); }); diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 287aefcdf307e..52c38c6a280f9 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -10,11 +10,11 @@ import type { FunctionComponent } from 'react'; import React from 'react'; import type { CoreStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { getUserDisplayName } from '../../common/model'; +import { useCurrentUser, useUserProfile } from '../components'; import { Breadcrumb } from '../components/breadcrumb'; -import { useCurrentUser, useUserProfile } from '../components/use_current_user'; import type { UserProfileProps } from './user_profile'; import { UserProfile } from './user_profile'; @@ -33,10 +33,13 @@ export const AccountManagementPage: FunctionComponent = () => { return null; } - const displayName = getUserDisplayName(userProfile.value.user); - return ( - + ); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx index 46cd1754fe17b..d6c2621ca45a2 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -6,7 +6,7 @@ */ import type { EuiAvatarProps } from '@elastic/eui'; -import { EuiAvatar, shade, useEuiTheme } from '@elastic/eui'; +import { EuiAvatar, useEuiTheme } from '@elastic/eui'; import type { FunctionComponent, HTMLAttributes } from 'react'; import React from 'react'; @@ -26,24 +26,15 @@ export interface UserAvatarProps extends Omit, 'c } export const UserAvatar: FunctionComponent = ({ user, avatar, ...rest }) => { - const { euiTheme, colorMode } = useEuiTheme(); + const { euiTheme } = useEuiTheme(); if (!user) { - const color = colorMode === 'LIGHT' ? euiTheme.colors.lightShade : euiTheme.colors.mediumShade; - return ( - - ); + return ; } const displayName = getUserDisplayName(user); - if (avatar && avatar.imageUrl) { + if (avatar?.imageUrl) { return ; } diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 1e9b08f970cf6..7b795ad877dd9 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -11,8 +11,10 @@ import React from 'react'; import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; +import { UserProfileAPIClient } from '..'; import type { UserData } from '../../../common'; import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { UserAPIClient } from '../../management'; import { securityMock } from '../../mocks'; import { Providers } from '../account_management_app'; import { useUserProfileForm } from './user_profile'; @@ -24,7 +26,16 @@ let history = scopedHistoryMock.create(); const authc = securityMock.createSetup().authc; const wrapper: FunctionComponent = ({ children }) => ( - + {children} ); @@ -43,7 +54,7 @@ describe('useUserProfileForm', () => { }; coreStart.http.delete.mockReset(); coreStart.http.get.mockReset(); - coreStart.http.post.mockReset(); + coreStart.http.post.mockReset().mockResolvedValue(undefined); coreStart.notifications.toasts.addDanger.mockReset(); coreStart.notifications.toasts.addSuccess.mockReset(); }); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index d86b96e0e920a..8ab6f71cfa0bf 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -21,6 +21,7 @@ import { EuiPageTemplate, EuiSpacer, EuiText, + useEuiTheme, useGeneratedHtmlId, } from '@elastic/eui'; import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; @@ -40,6 +41,7 @@ import { getUserAvatarColor, getUserAvatarInitials, } from '../../../common/model'; +import { useApiClients } from '../../components'; import { Breadcrumb } from '../../components/breadcrumb'; import { FormChangesProvider, @@ -49,11 +51,9 @@ import { import { FormField } from '../../components/form_field'; import { FormLabel } from '../../components/form_label'; import { FormRow, OptionalText } from '../../components/form_row'; -import { UserAPIClient } from '../../management/users'; import { ChangePasswordFlyout } from '../../management/users/edit_user/change_password_flyout'; import { isUserReserved } from '../../management/users/user_utils'; import { UserAvatar } from './user_avatar'; -import { UserProfileAPIClient } from './user_profile_api_client'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; export interface UserProfileProps { @@ -79,6 +79,7 @@ export interface UserProfileFormValues { } export const UserProfile: FunctionComponent = ({ user, data }) => { + const { euiTheme } = useEuiTheme(); const { services } = useKibana(); const formik = useUserProfileForm({ user, data }); const formChanges = useFormChanges(); @@ -101,7 +102,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) helpText: ( ), testSubj: 'username', @@ -121,7 +122,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) helpText: ( ), testSubj: 'full_name', @@ -140,7 +141,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) helpText: ( ), testSubj: 'email', @@ -165,7 +166,12 @@ export const UserProfile: FunctionComponent = ({ user, data }) ) : null} = ({ user, data }) )), }} bottomBar={formChanges.count > 0 ? : null} - restrictWidth={900} + bottomBarProps={{ paddingSize: 'm' }} + restrictWidth={1000} >
{canChangeDetails ? ( @@ -213,7 +220,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) description={ } > @@ -251,6 +258,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) = ({ user, data }) description={ } > - + {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( @@ -303,9 +311,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) = ({ user, data }) defaultMessage="Initials" /> ), - iconType: 'lettering', }, { id: 'image', @@ -367,9 +372,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) validate={{ required: i18n.translate( 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', - { - defaultMessage: 'Upload an image.', - } + { defaultMessage: 'Upload an image.' } ), }} accept={IMAGE_FILE_TYPES.join(',')} @@ -378,7 +381,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) /> ) : ( - + = ({ user, data }) > = ({ user, data }) validate={{ required: i18n.translate( 'xpack.security.accountManagement.userProfile.colorRequiredError', - { - defaultMessage: 'Select a color.', - } + { defaultMessage: 'Select a color.' } ), pattern: { value: VALID_HEX_COLOR, message: i18n.translate( 'xpack.security.accountManagement.userProfile.colorPatternError', - { - defaultMessage: 'Enter a valid HEX color code.', - } + { defaultMessage: 'Enter a valid HEX color code.' } ), }, }} @@ -526,6 +522,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) export function useUserProfileForm({ user, data }: UserProfileProps) { const { services } = useKibana(); + const { userProfiles, users } = useApiClients(); const [initialValues, resetInitialValues] = useState({ user: { @@ -547,7 +544,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { try { const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); const promises = [ - new UserProfileAPIClient(services.http).update( + userProfiles.update( values.avatarType === 'image' ? values.data : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } @@ -555,7 +552,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { ]; if (canChangeDetails) { promises.push( - new UserAPIClient(services.http).saveUser({ + users.saveUser({ username: user.username, roles: user.roles, enabled: user.enabled, @@ -581,6 +578,8 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { }, initialValues, enableReinitialize: true, + validateOnBlur: false, + validateOnChange: true, }); const customAvatarInitials = useRef( @@ -615,7 +614,7 @@ export const SaveChangesBottomBar: FunctionComponent = () => { const { count } = useFormChangesContext(); return ( - + @@ -631,7 +630,7 @@ export const SaveChangesBottomBar: FunctionComponent = () => { - + { + let coreStart: ReturnType; + let apiClient: UserProfileAPIClient; beforeEach(() => { - coreStart.http.delete.mockReset(); - coreStart.http.get.mockReset(); - coreStart.http.post.mockReset(); + coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue(undefined); + + apiClient = new UserProfileAPIClient(coreStart.http); }); it('should get user profile without retrieving any user data', async () => { diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts index 7d8a023e411ad..7c358fd4d3513 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -5,6 +5,9 @@ * 2.0. */ +import type { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; + import type { HttpStart } from '@kbn/core/public'; import type { AuthenticatedUserProfile, UserData } from '../../../common'; @@ -12,6 +15,13 @@ import type { AuthenticatedUserProfile, UserData } from '../../../common'; const USER_PROFILE_URL = '/internal/security/user_profile'; export class UserProfileAPIClient { + private readonly internalDataUpdates$: Subject = new Subject(); + + /** + * Emits event whenever user profile is changed by the user. + */ + public readonly dataUpdates$: Observable = this.internalDataUpdates$.asObservable(); + constructor(private readonly http: HttpStart) {} /** @@ -29,8 +39,8 @@ export class UserProfileAPIClient { * @param data Application data to be written (merged with existing data). */ public update(data: T) { - return this.http.post(`${USER_PROFILE_URL}/_data`, { - body: JSON.stringify(data), + return this.http.post(`${USER_PROFILE_URL}/_data`, { body: JSON.stringify(data) }).then(() => { + this.internalDataUpdates$.next(data); }); } } diff --git a/x-pack/plugins/security/public/components/api_clients_provider.ts b/x-pack/plugins/security/public/components/api_clients_provider.ts new file mode 100644 index 0000000000000..158a0855f172f --- /dev/null +++ b/x-pack/plugins/security/public/components/api_clients_provider.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import constate from 'constate'; + +import type { UserProfileAPIClient } from '../account_management'; +import type { UserAPIClient } from '../management'; + +export interface ApiClients { + userProfiles: UserProfileAPIClient; + users: UserAPIClient; +} + +const [ApiClientsProvider, useApiClients] = constate(({ userProfiles, users }: ApiClients) => ({ + userProfiles, + users, +})); + +export { ApiClientsProvider, useApiClients }; diff --git a/x-pack/plugins/security/public/components/form_changes.tsx b/x-pack/plugins/security/public/components/form_changes.tsx index 7d88c3af983ed..c2724e1d193fe 100644 --- a/x-pack/plugins/security/public/components/form_changes.tsx +++ b/x-pack/plugins/security/public/components/form_changes.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import type { FunctionComponent } from 'react'; -import React, { createContext, useContext, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; export interface FormChangesProps { /** @@ -73,11 +72,3 @@ export function useFormChangesContext() { return value; } - -/** - * Component that allows tracking changes within a form. - */ -export const FormChanges: FunctionComponent = ({ children }) => { - const value = useFormChanges(); - return {children}; -}; diff --git a/x-pack/plugins/security/public/components/index.ts b/x-pack/plugins/security/public/components/index.ts new file mode 100644 index 0000000000000..3f2ed4ed2e70d --- /dev/null +++ b/x-pack/plugins/security/public/components/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ApiClientsProvider, useApiClients, ApiClients } from './api_clients_provider'; +export { + AuthenticationProvider, + useAuthentication, + useUserProfile, + useCurrentUser, +} from './use_current_user'; diff --git a/x-pack/plugins/security/public/components/use_current_user.ts b/x-pack/plugins/security/public/components/use_current_user.ts index 7f9e02e1ed151..601cb42213724 100644 --- a/x-pack/plugins/security/public/components/use_current_user.ts +++ b/x-pack/plugins/security/public/components/use_current_user.ts @@ -7,12 +7,10 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; +import useObservable from 'react-use/lib/useObservable'; -import type { CoreStart } from '@kbn/core/public'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - +import { useApiClients } from '.'; import type { UserData } from '../../common'; -import { UserProfileAPIClient } from '../account_management'; import type { AuthenticationServiceSetup } from '../authentication'; export interface AuthenticationProviderProps { @@ -31,6 +29,7 @@ export function useCurrentUser() { } export function useUserProfile(dataPath?: string) { - const { services } = useKibana(); - return useAsync(() => new UserProfileAPIClient(services.http).get(dataPath), [services.http]); + const { userProfiles } = useApiClients(); + const dataUpdateState = useObservable(userProfiles.dataUpdates$); + return useAsync(() => userProfiles.get(dataPath), [userProfiles, dataUpdateState]); } diff --git a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx index d1134d0959a16..83cf8dae89416 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/user_form.tsx @@ -255,7 +255,7 @@ export const UserForm: FunctionComponent = ({ !isNewUser && !isReservedUser ? i18n.translate( 'xpack.security.management.users.userForm.changingUserNameAfterCreationDescription', - { defaultMessage: `Username can't be changed once created.` } + { defaultMessage: 'User name cannot be changed after account creation.' } ) : undefined } diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 399bd7162d3f8..8a37bb5c6153a 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -24,7 +24,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { UserAvatar as IUserAvatar } from '../../common'; import { getUserDisplayName, isUserAnonymous } from '../../common/model'; import { UserAvatar } from '../account_management'; -import { useCurrentUser, useUserProfile } from '../components/use_current_user'; +import { useCurrentUser, useUserProfile } from '../components'; export interface UserMenuLink { label: string; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index 29b4e4ccff080..47cf1027851f9 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -19,13 +19,15 @@ import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-pl import type { SecurityLicense } from '../../common/licensing'; import type { AuthenticationServiceSetup } from '../authentication'; -import { AuthenticationProvider } from '../components/use_current_user'; +import type { ApiClients } from '../components'; +import { ApiClientsProvider, AuthenticationProvider } from '../components'; import type { UserMenuLink } from './nav_control_component'; import { SecurityNavControl } from './nav_control_component'; interface SetupDeps { securityLicense: SecurityLicense; logoutUrl: string; + apiClients: ApiClients; } interface StartDeps { @@ -48,6 +50,7 @@ export interface SecurityNavControlServiceStart { export class SecurityNavControlService { private securityLicense!: SecurityLicense; private logoutUrl!: string; + private apiClients!: ApiClients; private navControlRegistered!: boolean; @@ -56,9 +59,10 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, logoutUrl }: SetupDeps) { + public setup({ securityLicense, logoutUrl, apiClients }: SetupDeps) { this.securityLicense = securityLicense; this.logoutUrl = logoutUrl; + this.apiClients = apiClients; } public start({ core, authc }: StartDeps): SecurityNavControlServiceStart { @@ -117,7 +121,7 @@ export class SecurityNavControlService { order: 2000, mount: (element: HTMLElement) => { ReactDOM.render( - + ; } @@ -149,13 +154,16 @@ export const Providers: FunctionComponent = ({ authc, services, theme$, + apiClients, children, }) => ( - - {children} - + + + {children} + + ); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index a9867c0324fbd..98505189d459c 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -23,12 +23,12 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import { SecurityLicenseService } from '../common/licensing'; import type { SecurityLicense } from '../common/licensing'; -import { accountManagementApp } from './account_management'; +import { accountManagementApp, UserProfileAPIClient } from './account_management'; import { AnonymousAccessService } from './anonymous_access'; import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication'; import { AuthenticationService } from './authentication'; import type { ConfigType } from './config'; -import { ManagementService } from './management'; +import { ManagementService, UserAPIClient } from './management'; import type { SecurityNavControlServiceStart } from './nav_control'; import { SecurityNavControlService } from './nav_control'; import { SecurityCheckupService } from './security_checkup'; @@ -91,15 +91,22 @@ export class SecurityPlugin http: core.http, }); + const apiClients = { + userProfiles: new UserProfileAPIClient(core.http), + users: new UserAPIClient(core.http), + }; + this.navControlService.setup({ securityLicense: license, logoutUrl: getLogoutUrl(core.http), + apiClients, }); accountManagementApp.create({ authc: this.authc, application: core.application, getStartServices: core.getStartServices, + apiClients, }); if (management) { From accd6c08f01493b002c03e99a72b5added979598 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 17 May 2022 15:57:16 +0200 Subject: [PATCH 41/47] Fix type errors. --- .../account_management_app.test.tsx | 8 ++++-- .../user_profile/user_profile.tsx | 2 +- .../security/public/components/index.ts | 3 ++- .../nav_control/nav_control_service.test.ts | 25 +++++++++++++++---- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx index ce6218ef23bfc..00f245a77febf 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -12,9 +12,11 @@ import type { AppUnmount } from '@kbn/core/public'; import { AppNavLinkStatus } from '@kbn/core/public'; import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; +import { UserAPIClient } from '../management'; import { securityMock } from '../mocks'; import { accountManagementApp } from './account_management_app'; import * as AccountManagementPageImports from './account_management_page'; +import { UserProfileAPIClient } from './user_profile'; const AccountManagementPageMock = jest .spyOn(AccountManagementPageImports, 'AccountManagementPage') @@ -23,12 +25,13 @@ const AccountManagementPageMock = jest describe('accountManagementApp', () => { it('should register application', () => { const { authc } = securityMock.createSetup(); - const { application, getStartServices } = coreMock.createSetup(); + const { application, getStartServices, http } = coreMock.createSetup(); accountManagementApp.create({ application, getStartServices, authc, + apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, }); expect(application.register).toHaveBeenCalledTimes(1); @@ -44,13 +47,14 @@ describe('accountManagementApp', () => { it('should render AccountManagementPage on mount', async () => { const { authc } = securityMock.createSetup(); - const { application, getStartServices } = coreMock.createSetup(); + const { application, getStartServices, http } = coreMock.createSetup(); getStartServices.mockResolvedValue([coreMock.createStart(), {}, {}]); accountManagementApp.create({ application, authc, getStartServices, + apiClients: { userProfiles: new UserProfileAPIClient(http), users: new UserAPIClient(http) }, }); const [[{ mount }]] = application.register.mock.calls; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 8ab6f71cfa0bf..61ee06db5225d 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -432,7 +432,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) } size="xs" flush="right" - style={{ height: 18 }} + style={{ height: euiTheme.base }} > ) => ({ + userProfiles: new UserProfileAPIClient(http), + users: new UserAPIClient(http), +}); + describe('SecurityNavControlService', () => { it('can render and cleanup the control via the mount() function', async () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); coreStart.chrome.navControls.registerRight = jest.fn(); navControlService.start({ core: coreStart, authc }); @@ -111,14 +120,15 @@ describe('SecurityNavControlService', () => { it('should register the nav control once the license supports it', () => { const license$ = new BehaviorSubject({} as ILicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); @@ -130,14 +140,15 @@ describe('SecurityNavControlService', () => { it('should not register the nav control for anonymous paths', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); coreStart.http.anonymousPaths.isAnonymous.mockReturnValue(true); navControlService.start({ core: coreStart, authc }); @@ -146,14 +157,15 @@ describe('SecurityNavControlService', () => { it('should only register the nav control once', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -167,14 +179,15 @@ describe('SecurityNavControlService', () => { it('should allow for re-registration if the service is restarted', () => { const license$ = new BehaviorSubject(validLicense); + const coreStart = coreMock.createStart(); const navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -188,12 +201,14 @@ describe('SecurityNavControlService', () => { describe(`#start`, () => { let navControlService: SecurityNavControlService; beforeEach(() => { + const coreSetup = coreMock.createSetup(); const license$ = new BehaviorSubject({} as ILicense); navControlService = new SecurityNavControlService(); navControlService.setup({ securityLicense: new SecurityLicenseService().setup({ license$ }).license, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreSetup.http), }); }); From 462d2c20c6ccfa32228c2a5df51098600956c4c6 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 18 May 2022 14:06:45 +0200 Subject: [PATCH 42/47] Use different validation model for the first and subsequent submits. --- .../user_profile/user_profile.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 61ee06db5225d..5dff38e8b864a 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -539,6 +539,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { avatarType: data.avatar?.imageUrl ? 'image' : 'initials', }); + const [validateOnBlurOrChange, setValidateOnBlurOrChange] = useState(false); const formik = useFormik({ onSubmit: async (values) => { try { @@ -578,10 +579,19 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { }, initialValues, enableReinitialize: true, - validateOnBlur: false, - validateOnChange: true, + validateOnBlur: validateOnBlurOrChange, + validateOnChange: validateOnBlurOrChange, }); + // We perform _the first_ validation only when the user submits the form to make UX less annoying. But after the user + // submits the form, the validation model changes to on blur/change (as the user's mindset has changed from completing + // the form to correcting the form). + if (formik.submitCount > 0 && !validateOnBlurOrChange) { + setValidateOnBlurOrChange(true); + } else if (formik.submitCount === 0 && validateOnBlurOrChange) { + setValidateOnBlurOrChange(false); + } + const customAvatarInitials = useRef( !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) ); From a34e0157646f6922f58e96fd9a04a797edd710d9 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 19 May 2022 12:29:50 +0200 Subject: [PATCH 43/47] Review#2: move change password form to a modal, support users authenticated via authentication proxies. --- .../common/model/authenticated_user.test.ts | 41 +- .../common/model/authenticated_user.ts | 9 + x-pack/plugins/security/common/model/index.ts | 7 +- .../account_management_page.tsx | 19 +- .../user_profile/user_profile.test.tsx | 2 +- .../user_profile/user_profile.tsx | 492 +++++++++--------- .../edit_user/change_password_flyout.tsx | 304 ----------- .../users/edit_user/change_password_modal.tsx | 303 +++++++++++ ...est.tsx => change_password_model.test.tsx} | 6 +- .../users/edit_user/edit_user_page.tsx | 4 +- .../server/routes/user_profile/get.ts | 3 +- .../server/routes/user_profile/update.ts | 3 +- .../user_profile/user_profile_service.ts | 1 - .../translations/translations/fr-FR.json | 16 - .../translations/translations/ja-JP.json | 16 - .../translations/translations/zh-CN.json | 16 - x-pack/test/functional/apps/security/users.ts | 8 +- .../page_objects/account_settings_page.ts | 6 +- .../functional/page_objects/security_page.ts | 2 +- 19 files changed, 647 insertions(+), 611 deletions(-) delete mode 100644 x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx create mode 100644 x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx rename x-pack/plugins/security/public/management/users/edit_user/{change_password_flyout.test.tsx => change_password_model.test.tsx} (97%) diff --git a/x-pack/plugins/security/common/model/authenticated_user.test.ts b/x-pack/plugins/security/common/model/authenticated_user.test.ts index e8c386d18c488..4c84a951bf729 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.test.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.test.ts @@ -8,7 +8,12 @@ import { applicationServiceMock } from '@kbn/core/public/mocks'; import type { AuthenticatedUser } from './authenticated_user'; -import { canUserChangeDetails, canUserChangePassword, isUserAnonymous } from './authenticated_user'; +import { + canUserChangeDetails, + canUserChangePassword, + canUserHaveProfile, + isUserAnonymous, +} from './authenticated_user'; import { mockAuthenticatedUser } from './authenticated_user.mock'; describe('canUserChangeDetails', () => { @@ -89,6 +94,40 @@ describe('isUserAnonymous', () => { }); }); +describe('canUserHaveProfile', () => { + it('anonymous users cannot have profiles', () => { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'basic1' }, + }) + ) + ).toBe(false); + }); + + it('proxy authenticated users cannot have profiles', () => { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: 'http', name: '__http__' }, + }) + ) + ).toBe(false); + }); + + it('non-anonymous users that can have sessions can have profiles', () => { + for (const providerType of ['saml', 'oidc', 'basic', 'token', 'pki', 'kerberos']) { + expect( + canUserHaveProfile( + mockAuthenticatedUser({ + authentication_provider: { type: providerType, name: `${providerType}_name` }, + }) + ) + ).toBe(true); + } + }); +}); + describe('#canUserChangePassword', () => { ['reserved', 'native'].forEach((realm) => { it(`returns true for users in the ${realm} realm`, () => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index 94ea8fd49c240..708cb00fbca50 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.ts @@ -48,6 +48,15 @@ export function isUserAnonymous(user: Pick ) { diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index f4e93d846e476..d655b23c0b439 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -21,7 +21,12 @@ export { } from './user_profile'; export { getUserDisplayName } from './user'; export type { AuthenticatedUser, UserRealm } from './authenticated_user'; -export { canUserChangePassword, canUserChangeDetails, isUserAnonymous } from './authenticated_user'; +export { + canUserChangePassword, + canUserChangeDetails, + isUserAnonymous, + canUserHaveProfile, +} from './authenticated_user'; export type { AuthenticationProvider } from './authentication_provider'; export { shouldProviderUseLoginForm } from './authentication_provider'; export type { BuiltinESPrivileges } from './builtin_es_privileges'; diff --git a/x-pack/plugins/security/public/account_management/account_management_page.tsx b/x-pack/plugins/security/public/account_management/account_management_page.tsx index 52c38c6a280f9..216ec8a4190b8 100644 --- a/x-pack/plugins/security/public/account_management/account_management_page.tsx +++ b/x-pack/plugins/security/public/account_management/account_management_page.tsx @@ -13,23 +13,32 @@ import type { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { UserAvatar } from '../../common'; +import { canUserHaveProfile } from '../../common/model'; import { useCurrentUser, useUserProfile } from '../components'; import { Breadcrumb } from '../components/breadcrumb'; -import type { UserProfileProps } from './user_profile'; import { UserProfile } from './user_profile'; export const AccountManagementPage: FunctionComponent = () => { const { services } = useKibana(); const currentUser = useCurrentUser(); - const userProfile = useUserProfile>('avatar'); + const userProfile = useUserProfile<{ avatar: UserAvatar }>('avatar'); - const error = currentUser.error || userProfile.error; + // If we fail to load profile, we treat it as a failure _only_ if user is supposed + // to have a profile. For example, anonymous and users authenticated via + // authentication proxies don't have profiles. + const profileLoadError = + userProfile.error && currentUser.value && canUserHaveProfile(currentUser.value) + ? userProfile.error + : undefined; + + const error = currentUser.error || profileLoadError; if (error) { return {error.message}} />; } - if (!currentUser.value || !userProfile.value) { + if (!currentUser.value || (canUserHaveProfile(currentUser.value) && !userProfile.value)) { return null; } @@ -40,7 +49,7 @@ export const AccountManagementPage: FunctionComponent = () => { })} href={services.http.basePath.prepend('/security/account')} > - + ); }; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx index 7b795ad877dd9..2861430d90353 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -112,7 +112,7 @@ describe('useUserProfileForm', () => { }); expect(result.current.values.user.full_name).toEqual('Another Name'); - expect(result.current.values.data.avatar.initials).toEqual('AN'); + expect(result.current.values.data?.avatar.initials).toEqual('AN'); }); it('should save user and user profile when submitting form', async () => { diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 5dff38e8b864a..88a36a2e7b72f 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -51,14 +51,14 @@ import { import { FormField } from '../../components/form_field'; import { FormLabel } from '../../components/form_label'; import { FormRow, OptionalText } from '../../components/form_row'; -import { ChangePasswordFlyout } from '../../management/users/edit_user/change_password_flyout'; +import { ChangePasswordModal } from '../../management/users/edit_user/change_password_modal'; import { isUserReserved } from '../../management/users/user_utils'; import { UserAvatar } from './user_avatar'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; export interface UserProfileProps { user: AuthenticatedUser; - data: { + data?: { avatar?: IUserAvatar; }; } @@ -68,7 +68,7 @@ export interface UserProfileFormValues { full_name: string; email: string; }; - data: { + data?: { avatar: { initials: string; color: string; @@ -84,7 +84,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) const formik = useUserProfileForm({ user, data }); const formChanges = useFormChanges(); const titleId = useGeneratedHtmlId(); - const [showChangePasswordFlyout, setShowChangePasswordFlyout] = useState(false); + const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); const isReservedUser = isUserReserved(user); const canChangePassword = canUserChangePassword(user); @@ -157,11 +157,11 @@ export const UserProfile: FunctionComponent = ({ user, data }) defaultMessage: 'Profile', })} > - {showChangePasswordFlyout ? ( - setShowChangePasswordFlyout(false)} - onSuccess={() => setShowChangePasswordFlyout(false)} + onCancel={() => setShowChangePasswordForm(false)} + onSuccess={() => setShowChangePasswordForm(false)} /> ) : null} @@ -256,220 +256,233 @@ export const UserProfile: FunctionComponent = ({ user, data }) ) : null} - + {formik.values.data ? ( + + + + } + description={ - - } - description={ - - } - > - - - {formik.values.avatarType === 'image' && !formik.values.data.avatar.imageUrl ? ( - - ) : ( - + + + {formik.values.avatarType === 'image' && + !formik.values.data.avatar.imageUrl ? ( + + ) : ( + - )} - - - - - - } - fullWidth - > - - ), - }, - { - id: 'image', - label: ( - - ), - iconType: 'image', - }, - ]} - onChange={(id: string) => formik.setFieldValue('avatarType', id)} - isFullWidth - /> - - - - - - {formik.values.avatarType === 'image' ? ( - - - - } - fullWidth - > - - } - onChange={createImageHandler((imageUrl) => { - formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); - })} - validate={{ - required: i18n.translate( - 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', - { defaultMessage: 'Upload an image.' } - ), - }} - accept={IMAGE_FILE_TYPES.join(',')} - display="default" - fullWidth - /> - - ) : ( - - + )} + + + } fullWidth > - ), }, - }} - fullWidth + { + id: 'image', + label: ( + + ), + iconType: 'image', + }, + ]} + onChange={(id: string) => formik.setFieldValue('avatarType', id)} + isFullWidth /> - - + + + + {formik.values.avatarType === 'image' ? ( + + + + } + fullWidth + > + - + ) : ( + + ) } - labelAppend={ - !isReservedUser ? ( - - formik.setFieldValue('data.avatar.color', getRandomColor()) - } - size="xs" - flush="right" - style={{ height: euiTheme.base }} - > + onChange={createImageHandler((imageUrl) => { + formik.setFieldValue('data.avatar.imageUrl', imageUrl ?? ''); + })} + validate={{ + required: i18n.translate( + 'xpack.security.accountManagement.userProfile.imageUrlRequiredError', + { defaultMessage: 'Upload an image.' } + ), + }} + accept={IMAGE_FILE_TYPES.join(',')} + display="default" + fullWidth + /> + + ) : ( + + + - - ) : null - } - fullWidth - > - + } + fullWidth + > + { - formik.setFieldValue('data.avatar.color', value); - }} + maxLength: { + value: 2, + message: i18n.translate( + 'xpack.security.accountManagement.userProfile.initialsMaxLengthError', + { defaultMessage: 'Enter no more than 2 characters.' } + ), + }, + }} + fullWidth + /> + + + + + + + } + labelAppend={ + !isReservedUser ? ( + + formik.setFieldValue('data.avatar.color', getRandomColor()) + } + size="xs" + flush="right" + style={{ height: euiTheme.base }} + > + + + ) : null + } fullWidth - /> - - - - )} - + > + { + formik.setFieldValue('data.avatar.color', value); + }} + fullWidth + /> + + + + )} + + ) : null} {canChangePassword ? ( = ({ user, data }) fullWidth > setShowChangePasswordFlyout(true)} + onClick={() => setShowChangePasswordForm(true)} iconType="lock" - data-test-subj="openChangePasswordFlyout" + data-test-subj="openChangePasswordForm" > ({ onSubmit: async (values) => { - try { - const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); - const promises = [ + const submitActions = []; + if (canUserChangeDetails(user, services.application.capabilities)) { + submitActions.push( + users.saveUser({ + username: user.username, + roles: user.roles, + enabled: user.enabled, + full_name: values.user.full_name, + email: values.user.email, + }) + ); + } + + // Update profile only if it's available for the current user. + if (values.data) { + submitActions.push( userProfiles.update( values.avatarType === 'image' ? values.data : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } - ), - ]; - if (canChangeDetails) { - promises.push( - users.saveUser({ - username: user.username, - roles: user.roles, - enabled: user.enabled, - full_name: values.user.full_name, - email: values.user.email, - }) - ); - } - await Promise.all(promises); - resetInitialValues(values); - services.notifications.toasts.addSuccess( - i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { - defaultMessage: 'Profile updated', - }) + ) ); + } + + if (submitActions.length === 0) { + return; + } + + try { + await Promise.all(submitActions); } catch (error) { services.notifications.toasts.addError(error, { title: i18n.translate('xpack.security.accountManagement.userProfile.submitErrorTitle', { defaultMessage: "Couldn't update profile", }), }); + return; } + + resetInitialValues(values); + services.notifications.toasts.addSuccess( + i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { + defaultMessage: 'Profile updated', + }) + ); }, initialValues, enableReinitialize: true, @@ -593,7 +619,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { } const customAvatarInitials = useRef( - !!data.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) + !!data?.avatar?.initials && data.avatar?.initials !== getUserAvatarInitials(user) ); useUpdateEffect(() => { @@ -607,14 +633,14 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { }, [formik.values.user.full_name]); useUpdateEffect(() => { - if (!customAvatarInitials.current) { + if (!customAvatarInitials.current && formik.values.data) { const defaultInitials = getUserAvatarInitials({ username: user.username, full_name: formik.values.user.full_name, }); customAvatarInitials.current = formik.values.data.avatar.initials !== defaultInitials; } - }, [formik.values.data.avatar.initials]); + }, [formik.values.data?.avatar.initials]); return formik; } diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx deleted file mode 100644 index 56edb43406dd7..0000000000000 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ /dev/null @@ -1,304 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EuiCallOut, - EuiFieldPassword, - EuiFlexGroup, - EuiFlexItem, - EuiForm, - EuiFormRow, - EuiIcon, - EuiLoadingContent, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import type { FunctionComponent } from 'react'; -import React from 'react'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; - -import { FormFlyout } from '../../../components/form_flyout'; -import { useCurrentUser } from '../../../components/use_current_user'; -import type { ValidationErrors } from '../../../components/use_form'; -import { useForm } from '../../../components/use_form'; -import { useInitialFocus } from '../../../components/use_initial_focus'; -import { UserAPIClient } from '../user_api_client'; - -export interface ChangePasswordFormValues { - current_password?: string; - password: string; - confirm_password: string; -} - -export interface ChangePasswordFlyoutProps { - username: string; - defaultValues?: ChangePasswordFormValues; - onCancel(): void; - onSuccess?(): void; -} - -export const validateChangePasswordForm = ( - values: ChangePasswordFormValues, - isCurrentUser: boolean -) => { - const errors: ValidationErrors = {}; - - if (isCurrentUser) { - if (!values.current_password) { - errors.current_password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError', - { - defaultMessage: 'Enter your current password.', - } - ); - } - } - - if (!values.password) { - errors.password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.passwordRequiredError', - { - defaultMessage: 'Enter a new password.', - } - ); - } else if (values.password.length < 6) { - errors.password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.passwordInvalidError', - { - defaultMessage: 'Enter at least 6 characters.', - } - ); - } else if (values.password !== values.confirm_password) { - errors.confirm_password = i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError', - { - defaultMessage: 'Passwords do not match.', - } - ); - } - - return errors; -}; - -export const ChangePasswordFlyout: FunctionComponent = ({ - username, - defaultValues = { - current_password: '', - password: '', - confirm_password: '', - }, - onSuccess, - onCancel, -}) => { - const { services } = useKibana(); - const { value: currentUser, loading: isLoading } = useCurrentUser(); - const isCurrentUser = currentUser?.username === username; - const isSystemUser = username === 'kibana' || username === 'kibana_system'; - - const [form, eventHandlers] = useForm({ - onSubmit: async (values) => { - try { - await new UserAPIClient(services.http!).changePassword( - username, - values.password, - values.current_password - ); - services.notifications!.toasts.addSuccess( - i18n.translate('xpack.security.management.users.changePasswordFlyout.successMessage', { - defaultMessage: "Password changed for '{username}'.", - values: { username }, - }) - ); - onSuccess?.(); - } catch (error) { - if ((error as any).body?.statusCode === 403) { - form.setError( - 'current_password', - i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError', - { - defaultMessage: 'Invalid password.', - } - ) - ); - } else { - services.notifications!.toasts.addDanger({ - title: i18n.translate( - 'xpack.security.management.users.changePasswordFlyout.errorMessage', - { - defaultMessage: 'Could not change password', - } - ), - text: (error as any).body?.message || error.message, - }); - throw error; - } - } - }, - validate: async (values) => validateChangePasswordForm(values, isCurrentUser), - defaultValues, - }); - - const firstFieldRef = useInitialFocus([isLoading]); - - return ( - - {isLoading ? ( - - ) : ( - - {isSystemUser ? ( - <> - -

- -

-

- -

-
- - - ) : undefined} - - {isCurrentUser ? ( - <> - - - - - ) : ( - - - - - - - - {username} - - - - - )} - - - - - - - - - {/* Hidden submit button is required for enter key to trigger form submission */} - -
- )} -
- ); -}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx new file mode 100644 index 0000000000000..bc0f07b607df3 --- /dev/null +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -0,0 +1,303 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldPassword, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiIcon, + EuiLoadingContent, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, + useGeneratedHtmlId, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { useCurrentUser } from '../../../components/use_current_user'; +import type { ValidationErrors } from '../../../components/use_form'; +import { useForm } from '../../../components/use_form'; +import { useInitialFocus } from '../../../components/use_initial_focus'; +import { UserAPIClient } from '../user_api_client'; + +export interface ChangePasswordFormValues { + current_password?: string; + password: string; + confirm_password: string; +} + +export interface ChangePasswordModalProps { + username: string; + defaultValues?: ChangePasswordFormValues; + onCancel(): void; + onSuccess?(): void; +} + +export const validateChangePasswordForm = ( + values: ChangePasswordFormValues, + isCurrentUser: boolean +) => { + const errors: ValidationErrors = {}; + + if (isCurrentUser) { + if (!values.current_password) { + errors.current_password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.currentPasswordRequiredError', + { defaultMessage: 'Enter your current password.' } + ); + } + } + + if (!values.password) { + errors.password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.passwordRequiredError', + { defaultMessage: 'Enter a new password.' } + ); + } else if (values.password.length < 6) { + errors.password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.passwordInvalidError', + { defaultMessage: 'Enter at least 6 characters.' } + ); + } else if (values.password !== values.confirm_password) { + errors.confirm_password = i18n.translate( + 'xpack.security.management.users.changePasswordForm.confirmPasswordInvalidError', + { defaultMessage: 'Passwords do not match.' } + ); + } + + return errors; +}; + +export const ChangePasswordModal: FunctionComponent = ({ + username, + defaultValues = { + current_password: '', + password: '', + confirm_password: '', + }, + onSuccess, + onCancel, +}) => { + const { services } = useKibana(); + const { value: currentUser, loading: isLoading } = useCurrentUser(); + const isCurrentUser = currentUser?.username === username; + const isSystemUser = username === 'kibana' || username === 'kibana_system'; + + const [form, eventHandlers] = useForm({ + onSubmit: async (values) => { + try { + await new UserAPIClient(services.http!).changePassword( + username, + values.password, + values.current_password + ); + services.notifications!.toasts.addSuccess( + i18n.translate('xpack.security.management.users.changePasswordForm.successMessage', { + defaultMessage: 'Password successfully changed.', + }) + ); + onSuccess?.(); + } catch (error) { + if ((error as any).body?.statusCode === 403) { + form.setError( + 'current_password', + i18n.translate( + 'xpack.security.management.users.changePasswordForm.currentPasswordInvalidError', + { defaultMessage: 'Invalid password.' } + ) + ); + } else { + services.notifications!.toasts.addDanger({ + title: i18n.translate( + 'xpack.security.management.users.changePasswordForm.errorMessage', + { defaultMessage: 'Could not change password' } + ), + text: (error as any).body?.message || error.message, + }); + throw error; + } + } + }, + validate: async (values) => validateChangePasswordForm(values, isCurrentUser), + defaultValues, + }); + + const firstFieldRef = useInitialFocus([isLoading]); + const modalFormId = useGeneratedHtmlId({ prefix: 'modalForm' }); + + return ( + + + + + + + + {isLoading ? ( + + ) : ( + + {isSystemUser ? ( + <> + +

+ +

+

+ +

+
+ + + ) : undefined} + + {isCurrentUser ? ( + <> + + + + + ) : ( + + + + + + + + {username} + + + + + )} + + + + + + + +
+ )} +
+ + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx similarity index 97% rename from x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx rename to x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx index ff97d8dcbb337..f040803ace705 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_model.test.tsx @@ -5,10 +5,10 @@ * 2.0. */ -import type { ChangePasswordFormValues } from './change_password_flyout'; -import { validateChangePasswordForm } from './change_password_flyout'; +import type { ChangePasswordFormValues } from './change_password_modal'; +import { validateChangePasswordForm } from './change_password_modal'; -describe('ChangePasswordFlyout', () => { +describe('ChangePasswordModal', () => { describe('#validateChangePasswordForm', () => { describe('for current user', () => { it('should show an error when it is current user with no current password', () => { diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx index a64ca3aca99eb..231ef70fed5bf 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.tsx @@ -32,7 +32,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { getUserDisplayName } from '../../../../common/model'; import { UserAPIClient } from '../user_api_client'; import { isUserDeprecated, isUserReserved } from '../user_utils'; -import { ChangePasswordFlyout } from './change_password_flyout'; +import { ChangePasswordModal } from './change_password_modal'; import { ConfirmDeleteUsers } from './confirm_delete_users'; import { ConfirmDisableUsers } from './confirm_disable_users'; import { ConfirmEnableUsers } from './confirm_enable_users'; @@ -157,7 +157,7 @@ export const EditUserPage: FunctionComponent = ({ username }) /> {action === 'changePassword' ? ( - setAction('none')} onSuccess={() => setAction('none')} diff --git a/x-pack/plugins/security/server/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 93cbdf59977c3..d1212dc064f7a 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -30,8 +30,9 @@ export function defineGetUserProfileRoute({ if (!session) { return response.notFound(); } + if (!session.userProfileId) { - logger.warn(`User profile missing from current session. (sid: ${session.sid})`); + logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); return response.notFound(); } diff --git a/x-pack/plugins/security/server/routes/user_profile/update.ts b/x-pack/plugins/security/server/routes/user_profile/update.ts index 483713ae31a22..333821508e96e 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -30,8 +30,9 @@ export function defineUpdateUserProfileDataRoute({ logger.warn('User profile requested without valid session.'); return response.notFound(); } + if (!session.userProfileId) { - logger.warn(`User profile missing from current session. (sid: ${session.sid})`); + logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); return response.notFound(); } diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 02ce1239643b7..228f40f1cb18f 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -29,7 +29,6 @@ export interface UserProfileServiceStart { /** * Updates user preferences by identifier. - * @param request Kibana request object * @param uid User ID * @param data Application data to be written (merged with existing data). */ diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 159a5dea1a984..e2f57394c1b7a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -23208,22 +23208,6 @@ "xpack.security.management.roles.statusColumnName": "Statut", "xpack.security.management.roles.subtitle": "Appliquez les rôles aux groupes d'utilisateurs et gérez les autorisations dans toute la Suite.", "xpack.security.management.rolesTitle": "Rôles", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{Modification du mot de passe…} other{Modifier le mot de passe}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "Les mots de passe ne correspondent pas.", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "Confirmer le mot de passe", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{Modification du mot de passe…} other{Modifier le mot de passe}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "Mot de passe non valide.", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "Mot de passe actuel", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "Entrez votre mot de passe actuel.", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "Impossible de modifier le mot de passe", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "Le mot de passe doit comporter au moins 6 caractères.", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "Nouveau mot de passe", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "Entrez un nouveau mot de passe.", - "xpack.security.management.users.changePasswordFlyout.successMessage": "Mot de passe modifié pour \"{username}\".", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "Une fois modifié, vous devez mettre à jour manuellement votre fichier config avec le nouveau mot de passe et redémarrer Kibana.", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "C'est extrêmement important !", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "La modification de ce mot de passe empêchera Kibana de communiquer avec Elasticsearch.", - "xpack.security.management.users.changePasswordFlyout.title": "Modifier le mot de passe", "xpack.security.management.users.changePasswordFlyout.userLabel": "Utilisateur", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "Annuler", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "Vous ne pouvez pas récupérer des utilisateurs supprimés.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b94058d0e3780..494b352f4ef8b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -23355,22 +23355,6 @@ "xpack.security.management.roles.statusColumnName": "ステータス", "xpack.security.management.roles.subtitle": "ユーザーのグループにロールを適用してスタック全体のパーミッションを管理します。", "xpack.security.management.rolesTitle": "ロール", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{パスワードを変更しています…} other{パスワードの変更}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "パスワードが一致していません。", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "パスワードの確認", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{パスワードを変更しています…} other{パスワードの変更}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "無効なパスワードです。", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "現在のパスワード", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "現在のパスワードを入力してください。", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "パスワードを変更できませんでした", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "パスワードは6文字以上でなければなりません。", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "新しいパスワード", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "新しいパスワードを入力してください。", - "xpack.security.management.users.changePasswordFlyout.successMessage": "'{username}'のパスワードが変更されました。", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "変更後は、新しいパスワードを使用して手動で構成ファイルを更新し、Kibanaを再起動する必要があります。", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "これは非常に重要です。", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "このパスワードを変更すると、KibanaはElasticsearchと通信できません。", - "xpack.security.management.users.changePasswordFlyout.title": "パスワードを変更", "xpack.security.management.users.changePasswordFlyout.userLabel": "ユーザー", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "キャンセル", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "削除したユーザーは復元できません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a4032bf776736..4895ffc6f380a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -23387,22 +23387,6 @@ "xpack.security.management.roles.statusColumnName": "状态", "xpack.security.management.roles.subtitle": "将角色应用到用户组并管理整个堆栈的权限。", "xpack.security.management.rolesTitle": "角色", - "xpack.security.management.users.changePasswordFlyout.confirmButton": "{isSubmitting, select, true{正在更改密码……} other{更改密码}}", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordInvalidError": "密码不匹配。", - "xpack.security.management.users.changePasswordFlyout.confirmPasswordLabel": "确认密码", - "xpack.security.management.users.changePasswordFlyout.confirmSystemPasswordButton": "{isSubmitting, select, true{正在更改密码……} other{更改密码}}", - "xpack.security.management.users.changePasswordFlyout.currentPasswordInvalidError": "密码无效。", - "xpack.security.management.users.changePasswordFlyout.currentPasswordLabel": "当前密码", - "xpack.security.management.users.changePasswordFlyout.currentPasswordRequiredError": "输入您的当前密码。", - "xpack.security.management.users.changePasswordFlyout.errorMessage": "无法更改密码", - "xpack.security.management.users.changePasswordFlyout.passwordInvalidError": "密码长度必须至少为 6 个字符。", - "xpack.security.management.users.changePasswordFlyout.passwordLabel": "新密码", - "xpack.security.management.users.changePasswordFlyout.passwordRequiredError": "输入新密码。", - "xpack.security.management.users.changePasswordFlyout.successMessage": "已为“{username}”更改密码。", - "xpack.security.management.users.changePasswordFlyout.systemUserDescription": "更改后,必须使用新密码手动更新您的配置文件,然后重新启动 Kibana。", - "xpack.security.management.users.changePasswordFlyout.systemUserTitle": "这极其重要!", - "xpack.security.management.users.changePasswordFlyout.systemUserWarning": "更改此密码将会阻止 Kibana 与 Elasticsearch 通信。", - "xpack.security.management.users.changePasswordFlyout.title": "更改密码", "xpack.security.management.users.changePasswordFlyout.userLabel": "用户", "xpack.security.management.users.confirmDelete.cancelButtonLabel": "取消", "xpack.security.management.users.confirmDelete.cannotUndoWarning": "您无法恢复删除的用户。", diff --git a/x-pack/test/functional/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 67be1e7ddecce..feeb70f80a486 100644 --- a/x-pack/test/functional/apps/security/users.ts +++ b/x-pack/test/functional/apps/security/users.ts @@ -175,9 +175,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be( - `Password changed for '${optionalUser.username}'.` - ); + expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); }); it('of current user when submitting form', async () => { @@ -196,9 +194,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { return toastCount >= 1; }); const successToast = await toasts.getToastElement(1); - expect(await successToast.getVisibleText()).to.be( - `Password changed for '${optionalUser.username}'.` - ); + expect(await successToast.getVisibleText()).to.be('Password successfully changed.'); }); }); diff --git a/x-pack/test/functional/page_objects/account_settings_page.ts b/x-pack/test/functional/page_objects/account_settings_page.ts index 9ae1d76a03fbf..85f644e8d42b9 100644 --- a/x-pack/test/functional/page_objects/account_settings_page.ts +++ b/x-pack/test/functional/page_objects/account_settings_page.ts @@ -24,7 +24,7 @@ export class AccountSettingsPageObject extends FtrService { } async changePassword(currentPassword: string, newPassword: string) { - await this.testSubjects.click('openChangePasswordFlyout'); + await this.testSubjects.click('openChangePasswordForm'); const currentPasswordInput = await this.find.byName('current_password'); await currentPasswordInput.clearValue(); @@ -38,10 +38,10 @@ export class AccountSettingsPageObject extends FtrService { await confirmPasswordInput.clearValue(); await confirmPasswordInput.type(newPassword); - await this.testSubjects.click('formFlyoutSubmitButton'); + await this.testSubjects.click('changePasswordFormSubmitButton'); const toast = await this.testSubjects.find('euiToastHeader'); const title = await toast.getVisibleText(); - expect(title).to.contain('Password changed'); + expect(title).to.contain('Password successfully changed'); } } diff --git a/x-pack/test/functional/page_objects/security_page.ts b/x-pack/test/functional/page_objects/security_page.ts index 8731a3a3f5459..622c6ad6c1afe 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -503,7 +503,7 @@ export class SecurityPageObject extends FtrService { 'editUserChangePasswordConfirmPasswordInput', user.confirm_password ?? '' ); - await this.testSubjects.click('formFlyoutSubmitButton'); + await this.testSubjects.click('changePasswordFormSubmitButton'); } async updateUserProfile(user: UserFormValues) { From a887dadaa9d1455da538cd0f5b13480dd7ba3279 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 19 May 2022 13:53:18 +0200 Subject: [PATCH 44/47] Use proper test subject for the password change Cancel button. --- .../public/management/users/edit_user/change_password_modal.tsx | 2 +- x-pack/test/accessibility/apps/users.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx index bc0f07b607df3..326f597867304 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -272,7 +272,7 @@ export const ChangePasswordModal: FunctionComponent = diff --git a/x-pack/test/accessibility/apps/users.ts b/x-pack/test/accessibility/apps/users.ts index 5833a19580c24..71ed3a27c1073 100644 --- a/x-pack/test/accessibility/apps/users.ts +++ b/x-pack/test/accessibility/apps/users.ts @@ -98,7 +98,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.clickLinkText('deleteA11y'); await find.clickByButtonText('Change password'); await a11y.testAppSnapshot(); - await testSubjects.click('formFlyoutCancelButton'); + await testSubjects.click('changePasswordFormCancelButton'); }); it('a11y test for deactivate user screen', async () => { From 81ed8d3580e8817ff869e4d4bb83c4d6b0181a8e Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 19 May 2022 15:40:19 +0200 Subject: [PATCH 45/47] Review#3: retry profile activation requests. --- .../user_profile/user_profile_service.test.ts | 103 +++++++++++++++++- .../user_profile/user_profile_service.ts | 71 +++++++++--- .../tests/session_idle/cleanup.ts | 9 +- .../tests/session_lifespan/cleanup.ts | 9 +- 4 files changed, 166 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts index f861024e1ba09..2da6ec8de944b 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.test.ts @@ -8,6 +8,7 @@ import { errors } from '@elastic/elasticsearch'; import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { nextTick } from '@kbn/test-jest-helpers'; import { userProfileMock } from '../../common/model/user_profile.mock'; import { securityMock } from '../mocks'; @@ -283,9 +284,9 @@ describe('UserProfileService', () => { }); }); - it('fails if activation fails', async () => { + it('fails if activation fails with non-409 error', async () => { const failureReason = new errors.ResponseError( - securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + securityMock.createApiResponse({ statusCode: 500, body: 'some message' }) ); mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( failureReason @@ -304,5 +305,103 @@ describe('UserProfileService', () => { body: { grant_type: 'access_token', access_token: 'some-token' }, }); }); + + it('retries activation if initially fails with 409 error', async () => { + jest.useFakeTimers(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.transport.request + .mockRejectedValueOnce(failureReason) + .mockResolvedValueOnce(userProfileMock.create()); + + const startContract = userProfileService.start(mockStartParams); + const activatePromise = startContract.activate({ + type: 'accessToken', + accessToken: 'some-token', + }); + await nextTick(); + jest.runAllTimers(); + + await expect(activatePromise).resolves.toMatchInlineSnapshot(` + Object { + "data": Object {}, + "enabled": true, + "uid": "some-profile-uid", + "user": Object { + "active": true, + "authentication_provider": Object { + "name": "basic1", + "type": "basic", + }, + "authentication_realm": Object { + "name": "native1", + "type": "native", + }, + "authentication_type": "realm", + "email": "email", + "enabled": true, + "full_name": "full name", + "lookup_realm": Object { + "name": "native1", + "type": "native", + }, + "metadata": Object { + "_reserved": false, + }, + "roles": Array [], + "username": "some-username", + }, + } + `); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 2 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); + + it('fails if activation max retries exceeded', async () => { + jest.useFakeTimers(); + + const failureReason = new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 409, body: 'some message' }) + ); + mockStartParams.clusterClient.asInternalUser.transport.request.mockRejectedValue( + failureReason + ); + + const startContract = userProfileService.start(mockStartParams); + + // Initial activation attempt. + const activatePromise = startContract.activate({ + type: 'accessToken', + accessToken: 'some-token', + }); + await nextTick(); + jest.runAllTimers(); + + // The first retry. + await nextTick(); + jest.runAllTimers(); + + // The second retry. + await nextTick(); + jest.runAllTimers(); + + await expect(activatePromise).rejects.toBe(failureReason); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes( + 3 + ); + expect(mockStartParams.clusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '_security/profile/_activate', + body: { grant_type: 'access_token', access_token: 'some-token' }, + }); + }); }); }); diff --git a/x-pack/plugins/security/server/user_profile/user_profile_service.ts b/x-pack/plugins/security/server/user_profile/user_profile_service.ts index 228f40f1cb18f..fcc62f87fd953 100644 --- a/x-pack/plugins/security/server/user_profile/user_profile_service.ts +++ b/x-pack/plugins/security/server/user_profile/user_profile_service.ts @@ -8,10 +8,12 @@ import type { IClusterClient, Logger } from '@kbn/core/server'; import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; -import { getDetailedErrorMessage } from '../errors'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; import type { UserProfileGrant } from './user_profile_grant'; const KIBANA_DATA_ROOT = 'kibana'; +const ACTIVATION_MAX_RETRIES = 3; +const ACTIVATION_RETRY_SCALE_DURATION_MS = 150; export interface UserProfileServiceStart { /** @@ -63,23 +65,56 @@ export class UserProfileService { async function activate(grant: UserProfileGrant): Promise { logger.debug(`Activating user profile via ${grant.type} grant.`); - try { - const response = await clusterClient.asInternalUser.transport.request({ - method: 'POST', - path: '_security/profile/_activate', - body: - grant.type === 'password' - ? { grant_type: 'password', username: grant.username, password: grant.password } - : { grant_type: 'access_token', access_token: grant.accessToken }, - }); - - logger.debug(`Successfully activated profile for "${response.user.username}".`); - - return response; - } catch (err) { - logger.error(`Failed to activate user profile: ${getDetailedErrorMessage(err)}.`); - throw err; - } + const activateGrant = + grant.type === 'password' + ? { grant_type: 'password', username: grant.username, password: grant.password } + : { grant_type: 'access_token', access_token: grant.accessToken }; + + // Profile activation is a multistep process that might or might not cause profile document to be created or + // updated. If Elasticsearch needs to handle multiple profile activation requests for the same user in parallel + // it can hit document version conflicts and fail (409 status code). In this case it's safe to retry activation + // request after some time. Most of the Kibana users won't be affected by this issue, but there are edge cases + // when users can be hit by the conflicts during profile activation, e.g. for PKI or Kerberos authentication when + // client certificate/ticket changes and multiple requests can trigger profile re-activation at the same time. + let activationRetriesLeft = ACTIVATION_MAX_RETRIES; + do { + try { + const response = await clusterClient.asInternalUser.transport.request({ + method: 'POST', + path: '_security/profile/_activate', + body: activateGrant, + }); + + logger.debug(`Successfully activated profile for "${response.user.username}".`); + + return response; + } catch (err) { + const detailedErrorMessage = getDetailedErrorMessage(err); + if (getErrorStatusCode(err) !== 409) { + logger.error(`Failed to activate user profile: ${detailedErrorMessage}.`); + throw err; + } + + activationRetriesLeft--; + logger.error( + `Failed to activate user profile (retries left: ${activationRetriesLeft}): ${detailedErrorMessage}.` + ); + + if (activationRetriesLeft === 0) { + throw err; + } + } + + await new Promise((resolve) => + setTimeout( + resolve, + (ACTIVATION_MAX_RETRIES - activationRetriesLeft) * ACTIVATION_RETRY_SCALE_DURATION_MS + ) + ); + } while (activationRetriesLeft > 0); + + // This should be unreachable code, unless we have a bug in retry handling logic. + throw new Error('Failed to activate user profile, max retries exceeded.'); } async function get(uid: string, dataPath?: string) { diff --git a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts index 4bdaa02846a07..c5639c26b8af4 100644 --- a/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_idle/cleanup.ts @@ -121,9 +121,12 @@ export default function ({ getService }: FtrProviderContext) { it('should properly clean up session expired because of idle timeout when providers override global session config', async function () { this.timeout(100000); - const samlDisableSessionCookie = await loginWithSAML('saml_disable'); - const samlOverrideSessionCookie = await loginWithSAML('saml_override'); - const samlFallbackSessionCookie = await loginWithSAML('saml_fallback'); + const [samlDisableSessionCookie, samlOverrideSessionCookie, samlFallbackSessionCookie] = + await Promise.all([ + loginWithSAML('saml_disable'), + loginWithSAML('saml_override'), + loginWithSAML('saml_fallback'), + ]); const response = await supertest .post('/internal/security/login') diff --git a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts index 2d3ed3473194b..f39e43856415b 100644 --- a/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts +++ b/x-pack/test/security_api_integration/tests/session_lifespan/cleanup.ts @@ -112,9 +112,12 @@ export default function ({ getService }: FtrProviderContext) { it('should properly clean up session expired because of lifespan when providers override global session config', async function () { this.timeout(100000); - const samlDisableSessionCookie = await loginWithSAML('saml_disable'); - const samlOverrideSessionCookie = await loginWithSAML('saml_override'); - const samlFallbackSessionCookie = await loginWithSAML('saml_fallback'); + const [samlDisableSessionCookie, samlOverrideSessionCookie, samlFallbackSessionCookie] = + await Promise.all([ + loginWithSAML('saml_disable'), + loginWithSAML('saml_override'), + loginWithSAML('saml_fallback'), + ]); const response = await supertest .post('/internal/security/login') From 3e282fe1bfdf3ba329fc7fe2977cd0a8d9df3f01 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 19 May 2022 16:32:29 +0200 Subject: [PATCH 46/47] Review#4: align text color of the non-editable profile fields with the designs. --- .../public/account_management/user_profile/user_profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index 88a36a2e7b72f..c5d8f852ac183 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -185,7 +185,7 @@ export const UserProfile: FunctionComponent = ({ user, data }) listItems={[ { title: ( - + {item.title} From 0349691065170df3f153f8c274f7f4b11dd1f9bb Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 23 May 2022 09:47:57 +0200 Subject: [PATCH 47/47] Review#4: improve warning message for the change password form when changing password for Kibana system users. --- .../management/users/edit_user/change_password_modal.tsx | 7 ++++--- x-pack/plugins/translations/translations/fr-FR.json | 4 ++-- x-pack/plugins/translations/translations/ja-JP.json | 4 ++-- x-pack/plugins/translations/translations/zh-CN.json | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx index 326f597867304..8b8ca9f393917 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -164,7 +164,7 @@ export const ChangePasswordModal: FunctionComponent = =

diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 0fba00ee7f468..3bf3d74491562 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -5367,10 +5367,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "Cette intégration n'est pas encore activée. Votre administrateur possède les autorisations requises pour l’activer.", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "Contactez votre administrateur", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Ajouter Elastic Agent", - "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "sharedUXComponents.pageTemplate.noDataCard.description": "Continuer sans collecter de données", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "Ajouter depuis la bibliothèque", + "sharedUXPackages.noDataViewsPrompt.learnMore": "Envie d'en savoir plus ?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "Lisez les documents", "telemetry.callout.appliesSettingTitle": "Les modifications apportées à ce paramètre s'appliquent dans {allOfKibanaText} et sont enregistrées automatiquement.", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "tout Kibana", "telemetry.callout.clusterStatisticsDescription": "Voici un exemple des statistiques de cluster de base que nous collecterons. Cela comprend le nombre d'index, de partitions et de nœuds. Cela comprend également des statistiques d'utilisation de niveau élevé, comme l'état d'activation du monitoring.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8985c2e28df8e..87f2b4c8b9f89 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -5469,10 +5469,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "この統合はまだ有効ではありません。管理者にはオンにするために必要なアクセス権があります。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "管理者にお問い合わせください", "sharedUXComponents.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "sharedUXComponents.pageTemplate.noDataCard.description": "データを収集せずに続行", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "ライブラリから追加", + "sharedUXPackages.noDataViewsPrompt.learnMore": "詳細について", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "ドキュメントを読む", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "Kibana のすべて", "telemetry.callout.clusterStatisticsDescription": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3b6ee74464a98..a3482588bb262 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -5480,10 +5480,10 @@ "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.description": "尚未启用此集成。您的管理员具有打开它所需的权限。", "sharedUXComponents.noDataPage.elasticAgentCard.noPermission.title": "请联系您的管理员", "sharedUXComponents.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", - "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "sharedUXComponents.pageTemplate.noDataCard.description": "继续,而不收集数据", "sharedUXComponents.toolbar.buttons.addFromLibrary.libraryButtonLabel": "从库中添加", + "sharedUXPackages.noDataViewsPrompt.learnMore": "希望了解详情?", + "sharedUXPackages.noDataViewsPrompt.readDocumentation": "阅读文档", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "整个 Kibana", "telemetry.callout.clusterStatisticsDescription": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。",