diff --git a/package.json b/package.json index e5fffb5b3a394..7ad52d25fe6f6 100644 --- a/package.json +++ b/package.json @@ -269,6 +269,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/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 97e9f23784f60..2386e2aae6f43 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -52,7 +52,7 @@ pageLoadAssetSize: savedObjectsTagging: 59482 savedObjectsTaggingOss: 20590 searchprofiler: 67080 - security: 95864 + security: 100000 snapshotRestore: 79032 spaces: 57868 telemetry: 51957 diff --git a/renovate.json b/renovate.json index 628eeec7c6e35..34ef1ea5bc0cf 100644 --- a/renovate.json +++ b/renovate.json @@ -115,6 +115,7 @@ "groupName": "platform security modules", "matchPackageNames": [ "node-forge", + "formik", "@types/node-forge", "require-in-the-middle", "tough-cookie", diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index 0da855b153be8..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, @@ -17,6 +18,10 @@ export type { RoleKibanaPrivilege, FeaturesPrivileges, User, + UserProfile, + UserData, + UserAvatar, + UserInfo, ApiKey, UserRealm, } from './model'; 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..4c84a951bf729 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,128 @@ * 2.0. */ +import { applicationServiceMock } from '@kbn/core/public/mocks'; + import type { AuthenticatedUser } from './authenticated_user'; -import { canUserChangePassword } from './authenticated_user'; +import { + canUserChangeDetails, + canUserChangePassword, + canUserHaveProfile, + 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('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) => { diff --git a/x-pack/plugins/security/common/model/authenticated_user.ts b/x-pack/plugins/security/common/model/authenticated_user.ts index d9fabc25df5ed..708cb00fbca50 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 '@kbn/core/types'; + import type { AuthenticationProvider } from './authentication_provider'; import type { User } from './user'; @@ -42,9 +44,31 @@ export interface AuthenticatedUser extends User { authentication_type: string; } -export function canUserChangePassword(user: AuthenticatedUser) { +export function isUserAnonymous(user: Pick) { + return user.authentication_provider.type === 'anonymous'; +} + +/** + * All users are supposed to have profiles except anonymous users and users authenticated + * via authentication HTTP proxies. + * @param user Authenticated user information. + */ +export function canUserHaveProfile(user: AuthenticatedUser) { + return !isUserAnonymous(user) && user.authentication_provider.type !== 'http'; +} + +export function canUserChangePassword( + user: Pick +) { return ( REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(user.authentication_realm.type) && - user.authentication_provider.type !== 'anonymous' + !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 84d7f261e51a7..d655b23c0b439 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -7,9 +7,26 @@ export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; export type { User, EditUser } from './user'; +export type { + AuthenticatedUserProfile, + UserProfile, + UserData, + UserInfo, + UserAvatar, +} 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 } 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/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/common/model/user_profile.mock.ts b/x-pack/plugins/security/common/model/user_profile.mock.ts new file mode 100644 index 0000000000000..fa6f34a1b603e --- /dev/null +++ b/x-pack/plugins/security/common/model/user_profile.mock.ts @@ -0,0 +1,29 @@ +/* + * 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 { mockAuthenticatedUser } from './authenticated_user.mock'; +import type { AuthenticatedUserProfile } from './user_profile'; + +export const userProfileMock = { + 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 new file mode 100644 index 0000000000000..fc5489ff0a173 --- /dev/null +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -0,0 +1,116 @@ +/* + * 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 '..'; +import type { AuthenticatedUser } from './authenticated_user'; +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 stored in user 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; +} + +/** + * User profile enriched with session information. + */ +export interface AuthenticatedUserProfile extends UserProfile { + /** + * Information about the currently authenticated user that owns the profile. + */ + user: UserProfile['user'] & Pick; +} + +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. + * 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.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts deleted file mode 100644 index 3ebeefd9c945b..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 '@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'; - -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..00f245a77febf --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.tsx @@ -0,0 +1,78 @@ +/* + * 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 '@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') + .mockReturnValue(null); + +describe('accountManagementApp', () => { + it('should register application', () => { + const { authc } = securityMock.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); + expect(application.register).toHaveBeenCalledWith( + 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, 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; + + let unmount: AppUnmount = noop; + await act(async () => { + unmount = await mount({ + element: document.createElement('div'), + 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_app.ts b/x-pack/plugins/security/public/account_management/account_management_app.ts deleted file mode 100644 index 98d810805a16c..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 type { ApplicationSetup, AppMountParameters, StartServicesAccessor } from '@kbn/core/public'; -import { AppNavLinkStatus } from '@kbn/core/public'; -import { i18n } from '@kbn/i18n'; - -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..104cc88591b4e --- /dev/null +++ b/x-pack/plugins/security/public/account_management/account_management_app.tsx @@ -0,0 +1,106 @@ +/* + * 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 type { + ApplicationSetup, + AppMountParameters, + CoreStart, + CoreTheme, + StartServicesAccessor, +} 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 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'; + +interface CreateDeps { + application: ApplicationSetup; + authc: AuthenticationServiceSetup; + apiClients: ApiClients; + getStartServices: StartServicesAccessor; +} + +export const accountManagementApp = Object.freeze({ + id: 'security_account', + create({ application, authc, getStartServices, apiClients }: 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; + apiClients: ApiClients; + onChange?: BreadcrumbsChangeHandler; +} + +export const Providers: FunctionComponent = ({ + services, + theme$, + history, + authc, + apiClients, + onChange, + 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 976f437beaa6b..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 @@ -5,142 +5,74 @@ * 2.0. */ -import { act } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; -import { coreMock } from '@kbn/core/public/mocks'; -import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; +import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/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 { 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'; -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 = mountWithIntl( - - ); - - 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 = mountWithIntl( - - ); - - 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 = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address'); - }); - - it(`displays change password form for users in the native realm`, async () => { - const user = createUser(); - const wrapper = mountWithIntl( - - ); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find('EuiFieldPassword[data-test-subj="currentPassword"]')).toHaveLength(1); - expect(wrapper.find('EuiFieldPassword[data-test-subj="newPassword"]')).toHaveLength(1); + 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; + + 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(`does not display change password form for users in the saml realm`, async () => { - const user = createUser({ realm: 'saml' }); - const wrapper = mountWithIntl( - + it('should render user profile form and set breadcrumbs', async () => { + const user = mockAuthenticatedUser(); + const data: UserData = {}; + + authc.getCurrentUser.mockResolvedValue(user); + coreStart.http.get.mockResolvedValue({ user, data }); + + 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: '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 5eb396204b6e8..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 @@ -5,80 +5,51 @@ * 2.0. */ -import { EuiPage, EuiPageBody, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; -import ReactDOM from 'react-dom'; - -import type { AppMountParameters, CoreStart, NotificationsStart } from '@kbn/core/public'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; -import type { PublicMethodsOf } from '@kbn/utility-types'; - -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; -} - -export const AccountManagementPage = ({ userAPIClient, authc, notifications }: Props) => { - const [currentUser, setCurrentUser] = useState(null); - useEffect(() => { - authc.getCurrentUser().then(setCurrentUser); - }, [authc]); +import { EuiEmptyPrompt } from '@elastic/eui'; +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 type { UserAvatar } from '../../common'; +import { canUserHaveProfile } from '../../common/model'; +import { useCurrentUser, useUserProfile } from '../components'; +import { Breadcrumb } from '../components/breadcrumb'; +import { UserProfile } from './user_profile'; + +export const AccountManagementPage: FunctionComponent = () => { + const { services } = useKibana(); + + const currentUser = useCurrentUser(); + const userProfile = useUserProfile<{ avatar: UserAvatar }>('avatar'); + + // 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) { + if (!currentUser.value || (canUserHaveProfile(currentUser.value) && !userProfile.value)) { return null; } return ( - - - - -

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

-
- - - - - - -
-
-
+ + + ); }; - -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..a05537136602a 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -6,6 +6,5 @@ */ export { accountManagementApp } from './account_management_app'; - -export type { ChangePasswordProps } from './change_password'; -export type { PersonalInfoProps } from './personal_info'; +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 new file mode 100644 index 0000000000000..f8a8f953aa833 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -0,0 +1,13 @@ +/* + * 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'; +export { UserProfileAPIClient } from './user_profile_api_client'; 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..d6c2621ca45a2 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_avatar.tsx @@ -0,0 +1,50 @@ +/* + * 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, useEuiTheme } 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, + USER_AVATAR_MAX_INITIALS, +} 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 { euiTheme } = useEuiTheme(); + + if (!user) { + return ; + } + + const displayName = getUserDisplayName(user); + + if (avatar?.imageUrl) { + return ; + } + + 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 new file mode 100644 index 0000000000000..2861430d90353 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.test.tsx @@ -0,0 +1,184 @@ +/* + * 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 '@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'; + +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(); + // @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().mockResolvedValue(undefined); + 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 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 }); + + 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 new file mode 100644 index 0000000000000..c5d8f852ac183 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -0,0 +1,694 @@ +/* + * 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, + EuiButtonGroup, + EuiColorPicker, + EuiDescribedFormGroup, + EuiDescriptionList, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiIconTip, + EuiPageTemplate, + EuiSpacer, + EuiText, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { Form, FormikProvider, useFormik, useFormikContext } from 'formik'; +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 { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { AuthenticatedUser, UserAvatar as IUserAvatar } from '../../../common'; +import { + canUserChangeDetails, + canUserChangePassword, + getUserAvatarColor, + getUserAvatarInitials, +} from '../../../common/model'; +import { useApiClients } from '../../components'; +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 { 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?: { + avatar?: IUserAvatar; + }; +} + +export interface UserProfileFormValues { + user: { + full_name: string; + email: string; + }; + data?: { + avatar: { + initials: string; + color: string; + imageUrl: string; + }; + }; + avatarType: 'initials' | 'image'; +} + +export const UserProfile: FunctionComponent = ({ user, data }) => { + const { euiTheme } = useEuiTheme(); + const { services } = useKibana(); + const formik = useUserProfileForm({ user, data }); + const formChanges = useFormChanges(); + const titleId = useGeneratedHtmlId(); + const [showChangePasswordForm, setShowChangePasswordForm] = useState(false); + + const isReservedUser = isUserReserved(user); + const canChangePassword = canUserChangePassword(user); + const canChangeDetails = canUserChangeDetails(user, services.application.capabilities); + + const rightSideItems = [ + { + title: ( + + ), + description: user.username, + helpText: ( + + ), + testSubj: 'username', + }, + ]; + + if (!canChangeDetails) { + if (user.full_name) { + rightSideItems.push({ + title: ( + + ), + description: user.full_name, + helpText: ( + + ), + testSubj: 'full_name', + }); + } + + if (user.email) { + rightSideItems.push({ + title: ( + + ), + description: user.email, + helpText: ( + + ), + testSubj: 'email', + }); + } + } + + return ( + + + + {showChangePasswordForm ? ( + setShowChangePasswordForm(false)} + onSuccess={() => setShowChangePasswordForm(false)} + /> + ) : null} + + + ), + pageTitleProps: { id: titleId }, + rightSideItems: rightSideItems.reverse().map((item) => ( + + + {item.title} + + + + + + ), + description: {item.description}, + }, + ]} + compressed + /> + )), + }} + bottomBar={formChanges.count > 0 ? : null} + bottomBarProps={{ paddingSize: 'm' }} + restrictWidth={1000} + > +
+ {canChangeDetails ? ( + + + + } + description={ + + } + > + + + + } + labelAppend={} + fullWidth + > + + + + + + + } + labelAppend={} + fullWidth + > + + + + ) : null} + + {formik.values.data ? ( + + + + } + description={ + + } + > + + + {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 + > + + + + + + + + } + 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 ? ( + + + + } + description={ + + } + > + + } + fullWidth + > + setShowChangePasswordForm(true)} + iconType="lock" + data-test-subj="openChangePasswordForm" + > + + + + + ) : null} +
+ +
+
+
+
+ ); +}; + +export function useUserProfileForm({ user, data }: UserProfileProps) { + const { services } = useKibana(); + const { userProfiles, users } = useApiClients(); + + const [initialValues, resetInitialValues] = useState({ + user: { + full_name: user.full_name || '', + email: user.email || '', + }, + data: data + ? { + avatar: { + initials: data.avatar?.initials || getUserAvatarInitials(user), + color: data.avatar?.color || getUserAvatarColor(user), + imageUrl: data.avatar?.imageUrl || '', + }, + } + : undefined, + avatarType: data?.avatar?.imageUrl ? 'image' : 'initials', + }); + + const [validateOnBlurOrChange, setValidateOnBlurOrChange] = useState(false); + const formik = useFormik({ + onSubmit: async (values) => { + 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 (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, + 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) + ); + + 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(() => { + 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]); + + return formik; +} + +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.test.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts new file mode 100644 index 0000000000000..b9cd558eb08cd --- /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 '@kbn/core/public/mocks'; + +import { UserProfileAPIClient } from './user_profile_api_client'; + +describe('UserProfileAPIClient', () => { + let coreStart: ReturnType; + let apiClient: UserProfileAPIClient; + beforeEach(() => { + coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue(undefined); + + apiClient = new UserProfileAPIClient(coreStart.http); + }); + + 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/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..7c358fd4d3513 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -0,0 +1,46 @@ +/* + * 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 { Observable } from 'rxjs'; +import { Subject } from 'rxjs'; + +import type { HttpStart } from '@kbn/core/public'; + +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) {} + + /** + * 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 profile data 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) }).then(() => { + this.internalDataUpdates$.next(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..fd15350288493 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/utils.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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); + } + + 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); + } + } catch (error) { + return reject(error); + } + + 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() * 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/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.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 new file mode 100644 index 0000000000000..c2724e1d193fe --- /dev/null +++ b/x-pack/plugins/security/public/components/form_changes.tsx @@ -0,0 +1,74 @@ +/* + * 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 FormChangesProps { + /** + * Number of fields rendered on the page that have changed. + */ + count: number; + + /** + * 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: ReportFunction; +} + +export type ReportFunction = (isEqual: boolean) => undefined | RevertFunction; +export type RevertFunction = () => void; + +/** + * Custom React hook that allows tracking changes within a form. + * + * @example + * ``` + * const { count } = useFormChanges(); // Form has {count} unsaved changes + * ``` + */ +export const useFormChanges = (): FormChangesProps => { + const [count, setCount] = useState(0); + + return { + count, + report: (isEqual) => { + if (!isEqual) { + setCount((c) => c + 1); + return () => setCount((c) => c - 1); + } + }, + }; +}; + +const FormChangesContext = createContext(undefined); + +export const FormChangesProvider = FormChangesContext.Provider; + +/** + * Custom React hook that returns all @see FormChangesProps state from context. + * + * @throws Error if called within a component that isn't a child of a `` component. + */ +export function useFormChangesContext() { + const value = useContext(FormChangesContext); + + if (!value) { + throw new Error( + 'FormChanges context is undefined, please verify you are calling useFormChangesContext() as child of a component.' + ); + } + + return value; +} 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 new file mode 100644 index 0000000000000..6e223f067c99b --- /dev/null +++ b/x-pack/plugins/security/public/components/form_field.tsx @@ -0,0 +1,125 @@ +/* + * 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 { 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 { + as?: T; + name: string; + validate?: FieldValidator | ValidateOptions; +} + +/** + * Polymorphic component that renders a form field with all state required for inline validation. + * + * @example Text field with validation rule: + * ```typescript + * + * + * + * ``` + * + * @example Color picker using non-standard value prop and change handler: + * ```typescript + * + * formik.setFieldValue('color', value)} + * /> + * + * ``` + * + * @throws Error if not a child of a `` component. + */ +export function FormField({ + as, + validate, + onBlur, + ...rest +}: FormFieldProps & Omit, keyof FormFieldProps>) { + const Component = as || EuiFieldText; + + const [field, meta, helpers] = useField({ + name: rest.name, + validate: typeof validate === 'object' ? createFieldValidator(validate) : validate, + }); + + return ( + { + 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); + }} + /> + ); +} + +export interface ValidateOptions { + required?: string; + pattern?: { + value: RegExp; + message: string; + }; + minLength?: { + value: number; + message: string; + }; + maxLength?: { + value: number; + message: string; + }; + min?: { + value: number; + message: string; + }; + max?: { + value: number; + message: string; + }; +} + +export function createFieldValidator(options: ValidateOptions): FieldValidator { + return (value: any) => { + if (options.required && typeof value !== 'number' && !value) { + return options.required; + } + 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 && + value && + (typeof value === 'object' || typeof value === 'string') && + value.length > options.maxLength.value + ) { + return options.maxLength.message; + } + if (options.min && (isNaN(value) || value < options.min.value)) { + return options.min.message; + } + if (options.max && (isNaN(value) || value > options.max.value)) { + return options.max.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 new file mode 100644 index 0000000000000..724dfa9902b02 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_label.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { useFormikContext } from 'formik'; +import type { FunctionComponent } from 'react'; +import React, { useEffect } from 'react'; + +import { useFormChangesContext } from './form_changes'; + +export interface FormLabelProps { + /** + * Name of target form field. + */ + for: string; +} + +/** + * Component that visually indicates whether a field value has changed. + * + * @example Renders a dot next to "Email" label when field value changes. + * ```typescript + * + * + * 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(); + const { report } = useFormChangesContext(); + + const meta = formik.getFieldMeta(props.for); + const isEqual = meta.value === meta.initialValue; + + useEffect(() => report(isEqual), [isEqual]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + {props.children} + {!isEqual ? ( + + + + ) : undefined} + + ); +}; 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..962bf5e79f974 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_row.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 { 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 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/public/components/form_row.tsx b/x-pack/plugins/security/public/components/form_row.tsx new file mode 100644 index 0000000000000..a6f4e475c3e19 --- /dev/null +++ b/x-pack/plugins/security/public/components/form_row.tsx @@ -0,0 +1,66 @@ +/* + * 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'; + +export interface FormRowProps { + /** + * Optional name of form field. + * + * If not provided the name will be inferred from its child element. + */ + name?: string; +} + +/** + * Component that renders a form row with all error states for inline validation. + * + * @example + * ```typescript + * + * + * + * + * + * ``` + * + * @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(); + const child = Children.only(props.children); + const name = props.name ?? child.props.name; + + if (!name) { + throw new Error( + 'name prop is undefined, please verify you are either rendering itself or its child with a name prop.' + ); + } + + const meta = formik.getFieldMeta(name); + + return ( + + {child} + + ); +}; + +export const OptionalText: FunctionComponent = () => { + return ( + + + + ); +}; 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..9f3eaacb61f49 --- /dev/null +++ b/x-pack/plugins/security/public/components/index.ts @@ -0,0 +1,15 @@ +/* + * 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 } from './api_clients_provider'; +export type { 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 103952d7d34ef..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,7 +7,10 @@ import constate from 'constate'; import useAsync from 'react-use/lib/useAsync'; +import useObservable from 'react-use/lib/useObservable'; +import { useApiClients } from '.'; +import type { UserData } from '../../common'; import type { AuthenticationServiceSetup } from '../authentication'; export interface AuthenticationProviderProps { @@ -24,3 +27,9 @@ export function useCurrentUser() { const authc = useAuthentication(); return useAsync(authc.getCurrentUser, [authc]); } + +export function useUserProfile(dataPath?: string) { + const { userProfiles } = useApiClients(); + const dataUpdateState = useObservable(userProfiles.dataUpdates$); + return useAsync(() => userProfiles.get(dataPath), [userProfiles, dataUpdateState]); +} diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 158af4f2de8d2..3940361ad72bd 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -18,9 +18,8 @@ import { SecurityPlugin } from './plugin'; export type { SecurityPluginSetup, SecurityPluginStart }; export type { AuthenticatedUser } from '../common/model'; export type { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; +export type { UiApi, ChangePasswordProps, PersonalInfoProps } from './ui_api'; export type { UserMenuLink, SecurityNavControlServiceStart } from './nav_control'; -export type { UiApi } from './ui_api'; -export type { PersonalInfoProps, ChangePasswordProps } from './account_management'; export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication'; 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 53c298e270112..0000000000000 --- a/x-pack/plugins/security/public/management/users/edit_user/change_password_flyout.tsx +++ /dev/null @@ -1,297 +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: 'Password must be 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?.message === 'security_exception') { - 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} - - - - - - - - - {username} - - - - - - {isCurrentUser ? ( - - - - ) : null} - - - - - - - - - {/* 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..8b8ca9f393917 --- /dev/null +++ b/x-pack/plugins/security/public/management/users/edit_user/change_password_modal.tsx @@ -0,0 +1,304 @@ +/* + * 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 90% 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 b0b8ca2030fa0..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', () => { @@ -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.", } `); }); 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/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.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..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 @@ -5,28 +5,57 @@ * 2.0. */ -import { EuiContextMenuItem, EuiHeaderSectionItemButton, EuiPopover } from '@elastic/eui'; +import { EuiContextMenu } from '@elastic/eui'; +import { shallow } from 'enzyme'; +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 { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test-jest-helpers'; - -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'; +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 userMenuLinks$ = new BehaviorSubject([]); + 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, + }); + + useCurrentUserMock.mockReset(); + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser(), + }); + + useObservableMock.mockReset(); + useObservableMock.mockImplementation( + (observable: BehaviorSubject, initialValue = {}) => observable.value ?? initialValue + ); + }); + + it('should render an avatar when user profile has loaded', async () => { + const wrapper = shallow( + + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > - `); }); - 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, + }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); + + const wrapper = shallow( + + ); + + expect(useUserProfileMock).toHaveBeenCalledTimes(1); + expect(useCurrentUserMock).toHaveBeenCalledTimes(1); + expect(wrapper.prop('button')).toMatchInlineSnapshot(` { aria-label="Account menu" data-test-subj="userMenuButton" onClick={[Function]} + style={ + Object { + "lineHeight": "normal", + } + } > - `); }); - 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([]), - }; + it('should open popover when avatar is clicked', async () => { + const wrapper = shallow( + + ); - const wrapper = mountWithIntl(); - // not awaiting the user promise + 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); - - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); - - 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([]), - }; - - const wrapper = mountWithIntl(); - await nextTick(); - wrapper.update(); + it('should not open popover while loading', () => { + useUserProfileMock.mockReturnValue({ + loading: true, + }); + useCurrentUserMock.mockReturnValue({ + loading: true, + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + const wrapper = shallow( + + ); - wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + act(() => { + wrapper.prop('button').props.onClick(); + wrapper.update(); + }); - expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); - expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); - expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + expect(wrapper.prop('isOpen')).toEqual(false); }); - 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); - }); - - 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( + + ); + + 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( + + ); + + 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: undefined, + error: new Error('404'), + }); + + useCurrentUserMock.mockReturnValue({ + loading: false, + value: mockAuthenticatedUser({ + authentication_provider: { type: 'anonymous', name: 'does no matter' }, + }), + }); + + const wrapper = shallow( + + ); + + 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 541d0c161a87e..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 @@ -5,24 +5,26 @@ * 2.0. */ -import './nav_control_component.scss'; - import type { EuiContextMenuPanelItemDescriptor, IconType } from '@elastic/eui'; import { - EuiAvatar, EuiContextMenu, EuiHeaderSectionItemButton, EuiIcon, EuiLoadingSpinner, EuiPopover, } from '@elastic/eui'; -import React, { Component } from 'react'; -import type { Observable, Subscription } from 'rxjs'; +import type { FunctionComponent } from 'react'; +import React, { useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import type { Observable } from 'rxjs'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { AuthenticatedUser } from '../../common/model'; +import type { UserAvatar as IUserAvatar } from '../../common'; +import { getUserDisplayName, isUserAnonymous } from '../../common/model'; +import { UserAvatar } from '../account_management'; +import { useCurrentUser, useUserProfile } from '../components'; export interface UserMenuLink { label: string; @@ -32,174 +34,131 @@ export interface UserMenuLink { setAsProfile?: boolean; } -interface Props { - user: Promise; +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<{ avatar: IUserAvatar }>('avatar'); + const currentUser = useCurrentUser(); // User profiles do not exist for anonymous users so need to fetch current user as well + + const displayName = currentUser.value ? getUserDisplayName(currentUser.value) : ''; + + const button = ( + setIsOpen((value) => (currentUser.value ? !value : false))} + data-test-subj="userMenuButton" + style={{ lineHeight: 'normal' }} + > + {currentUser.value && userProfile.value ? ( + + ) : currentUser.value && userProfile.error ? ( + ) : ( + + )} + + ); + + const isAnonymous = currentUser.value ? isUserAnonymous(currentUser.value) : false; + 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 (!isAnonymous) { + const hasCustomProfileLinks = userMenuLinks.some(({ setAsProfile }) => setAsProfile === true); + const profileMenuItem: EuiContextMenuPanelItemDescriptor = { + 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); + } } -} + + items.push({ + name: isAnonymous ? ( + + ) : ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }); + + return ( + setIsOpen(false)} + panelPaddingSize="none" + buffer={0} + > +
+ +
+
+ ); +}; 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 c801a1f8f4e1b..93d2927f678a8 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 @@ -7,15 +7,29 @@ import { BehaviorSubject } from 'rxjs'; +import type { httpServiceMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks'; import type { ILicense } from '@kbn/licensing-plugin/public'; import { nextTick } from '@kbn/test-jest-helpers'; import { SecurityLicenseService } from '../../common/licensing'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; -import { securityMock } from '../mocks'; +import { UserProfileAPIClient } from '../account_management'; +import { authenticationMock } from '../authentication/index.mock'; +import * as UseCurrentUserImports from '../components/use_current_user'; +import { UserAPIClient } from '../management'; import { SecurityNavControlService } from './nav_control_service'; +const useUserProfileMock = jest.spyOn(UseCurrentUserImports, 'useUserProfile'); +const useCurrentUserMock = jest.spyOn(UseCurrentUserImports, 'useCurrentUser'); + +useUserProfileMock.mockReturnValue({ + loading: true, +}); + +useCurrentUserMock.mockReturnValue({ + loading: true, +}); + const validLicense = { isAvailable: true, getFeature: (feature) => { @@ -29,25 +43,28 @@ const validLicense = { hasAtLeast: (...candidates) => true, } as ILicense; +const authc = authenticationMock.createStart(); + +const mockApiClients = (http: ReturnType) => ({ + 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(); - 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', + apiClients: mockApiClients(coreStart.http), }); - 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]; @@ -72,6 +89,7 @@ describe('SecurityNavControlService', () => { aria-label="Account menu" class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" data-test-subj="userMenuButton" + style="line-height: normal;" type="button" > { - + @@ -113,16 +120,16 @@ 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, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).not.toHaveBeenCalled(); @@ -133,33 +140,33 @@ 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, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - 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(); }); 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, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - const coreStart = coreMock.createStart(); - navControlService.start({ core: coreStart }); + navControlService.start({ core: coreStart, authc }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(1); @@ -172,48 +179,52 @@ 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, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreStart.http), }); - 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); }); 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, - authc: securityMock.createSetup().authc, logoutUrl: '/some/logout/url', + apiClients: mockApiClients(coreSetup.http), }); }); 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([ @@ -240,7 +251,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([ @@ -307,7 +321,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([ @@ -327,7 +344,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 63e753f8646cd..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 @@ -6,28 +6,33 @@ */ import { sortBy } from 'lodash'; +import type { FunctionComponent } from 'react'; import React from 'react'; import ReactDOM from 'react-dom'; import type { Observable, Subscription } from 'rxjs'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import type { CoreStart } from '@kbn/core/public'; -import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import type { CoreStart, CoreTheme } from '@kbn/core/public'; +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 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; - authc: AuthenticationServiceSetup; logoutUrl: string; + apiClients: ApiClients; } interface StartDeps { core: CoreStart; + authc: AuthenticationServiceSetup; } export interface SecurityNavControlServiceStart { @@ -44,8 +49,8 @@ export interface SecurityNavControlServiceStart { export class SecurityNavControlService { private securityLicense!: SecurityLicense; - private authc!: AuthenticationServiceSetup; private logoutUrl!: string; + private apiClients!: ApiClients; private navControlRegistered!: boolean; @@ -54,13 +59,13 @@ export class SecurityNavControlService { private readonly stop$ = new ReplaySubject(1); private userMenuLinks$ = new BehaviorSubject([]); - public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { + public setup({ securityLicense, logoutUrl, apiClients }: SetupDeps) { this.securityLicense = securityLicense; - this.authc = authc; this.logoutUrl = logoutUrl; + this.apiClients = apiClients; } - 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); @@ -68,7 +73,7 @@ export class SecurityNavControlService { const shouldRegisterNavControl = !isAnonymousPath && showLinks && !this.navControlRegistered; if (shouldRegisterNavControl) { - this.registerSecurityNavControl(core); + this.registerSecurityNavControl(core, authc); } } ); @@ -110,32 +115,23 @@ export class SecurityNavControlService { this.stop$.next(); } - private registerSecurityNavControl( - core: Pick - ) { + private registerSecurityNavControl(core: CoreStart, authc: AuthenticationServiceSetup) { 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); }, }); @@ -146,3 +142,28 @@ export class SecurityNavControlService { return sortBy(userMenuLinks, 'order'); } } + +export interface ProvidersProps { + authc: AuthenticationServiceSetup; + services: CoreStart; + apiClients: ApiClients; + theme$: Observable; +} + +export const Providers: FunctionComponent = ({ + authc, + services, + theme$, + apiClients, + children, +}) => ( + + + + + {children} + + + + +); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 7ca2e2f353efa..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,16 +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, - authc: this.authc, logoutUrl: getLogoutUrl(core.http), + apiClients, }); accountManagementApp.create({ authc: this.authc, application: core.application, getStartServices: core.getStartServices, + apiClients, }); if (management) { @@ -167,7 +173,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, }; } @@ -206,6 +212,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 5c899ab8c5ab5..7ab626bad1571 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 '@kbn/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 ba2e1bfb5f36b..7029a0547c70a 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 '@kbn/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/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 3b4150bbcaa8d..0959479e8023f 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/routes/user_profile/get.ts b/x-pack/plugins/security/server/routes/user_profile/get.ts index 9bfa2fe16a682..d1212dc064f7a 100644 --- a/x-pack/plugins/security/server/routes/user_profile/get.ts +++ b/x-pack/plugins/security/server/routes/user_profile/get.ts @@ -7,27 +7,43 @@ import { schema } from '@kbn/config-schema'; -import type { RouteDefinitionParams } from '../'; +import type { RouteDefinitionParams } from '..'; +import type { AuthenticatedUserProfile } from '../../../common'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineGetUserProfileRoute({ router, + getSession, getUserProfileService, + logger, }: RouteDefinitionParams) { router.get( { - path: '/internal/security/user_profile/{uid}', - options: { tags: ['access:accessUserProfile'] }, + path: '/internal/security/user_profile', 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.notFound(); + } + + if (!session.userProfileId) { + logger.warn(`User profile missing from current session. (sid: ${session.sid.slice(-10)})`); + 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); + const body: AuthenticatedUserProfile = { + ...profile, + user: { ...profile.user, authentication_provider: session.provider }, + }; + return response.ok({ body }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); } 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 00cbfea3a957b..333821508e96e 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -7,27 +7,38 @@ import { schema } from '@kbn/config-schema'; -import type { RouteDefinitionParams } from '../'; +import type { RouteDefinitionParams } from '..'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; export function defineUpdateUserProfileDataRoute({ router, + getSession, getUserProfileService, + logger, }: RouteDefinitionParams) { router.post( { - path: '/internal/security/user_profile/_data/{uid}', - options: { tags: ['access:updateUserProfile'] }, + path: '/internal/security/user_profile/_data', 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) { + 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.slice(-10)})`); + return response.notFound(); + } + const userProfileService = getUserProfileService(); try { - await userProfileService.update(request.params.uid, request.body); + await userProfileService.update(session.userProfileId, request.body); return response.ok(); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); 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'; diff --git a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts b/x-pack/plugins/security/server/user_profile/user_profile.mock.ts deleted file mode 100644 index 63573b62215f5..0000000000000 --- a/x-pack/plugins/security/server/user_profile/user_profile.mock.ts +++ /dev/null @@ -1,18 +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 { UserProfile } 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, - }), -}; 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..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 '../user_profile'; -import { userProfileMock } from './user_profile.mock'; +import type { UserProfileServiceStart } from '.'; +import { userProfileMock } from '../../common/model/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..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 @@ -7,10 +7,11 @@ import { errors } from '@elastic/elasticsearch'; -import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; +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'; -import { userProfileMock } from './user_profile.mock'; import { UserProfileService } from './user_profile_service'; const logger = loggingSystemMock.createLogger(); @@ -61,20 +62,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 +112,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.*', @@ -129,7 +166,7 @@ describe('UserProfileService', () => { }, }, method: 'POST', - path: '_security/profile/_data/UID', + path: '_security/profile/UID/_data', }); }); @@ -163,18 +200,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 +244,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 ); @@ -211,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 @@ -232,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 dbdc6d17e1581..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 @@ -5,13 +5,15 @@ * 2.0. */ -import type { IClusterClient, Logger } from 'src/core/server'; +import type { IClusterClient, Logger } from '@kbn/core/server'; -import { getDetailedErrorMessage } from '../errors'; -import type { UserData, UserInfo, UserProfile } from './user_profile'; +import type { AuthenticationProvider, UserData, UserInfo, UserProfile } from '../../common'; +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 { /** @@ -46,6 +48,7 @@ type GetProfileResponse = Record< access: {}; enabled: boolean; last_synchronized: number; + authentication_provider: AuthenticationProvider; } >; @@ -62,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) { @@ -89,8 +125,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; @@ -101,7 +136,7 @@ export class UserProfileService { try { await clusterClient.asInternalUser.transport.request({ method: 'POST', - path: `_security/profile/_data/${uid}`, + path: `_security/profile/${uid}/_data`, body: { data: { [KIBANA_DATA_ROOT]: data, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index b62a957cfa927..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.", @@ -22784,7 +22784,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.", @@ -23196,22 +23195,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 fe7056a5e3ec1..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": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。", @@ -22921,7 +22921,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": "この情報は変更できません。", @@ -23333,22 +23332,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 990a113fcd9d6..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": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。", @@ -22953,7 +22953,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": "不能更改此信息。", @@ -23365,22 +23364,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/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 () => { 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/apps/security/users.ts b/x-pack/test/functional/apps/security/users.ts index 8448750bf1ccd..444e52a79602f 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 5bf5be9030b75..85f644e8d42b9 100644 --- a/x-pack/test/functional/page_objects/account_settings_page.ts +++ b/x-pack/test/functional/page_objects/account_settings_page.ts @@ -9,27 +9,39 @@ import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; export class AccountSettingsPageObject extends FtrService { + private readonly find = this.ctx.getService('find'); 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(); } async changePassword(currentPassword: string, newPassword: string) { - await this.testSubjects.setValue('currentPassword', currentPassword); - await this.testSubjects.setValue('newPassword', newPassword); - await this.testSubjects.setValue('confirmNewPassword', newPassword); - await this.testSubjects.click('changePasswordButton'); - await this.testSubjects.existOrFail('passwordUpdateSuccess'); + await this.testSubjects.click('openChangePasswordForm'); + + const currentPasswordInput = await this.find.byName('current_password'); + await currentPasswordInput.clearValue(); + await currentPasswordInput.type(currentPassword); + + const passwordInput = await this.find.byName('password'); + await passwordInput.clearValue(); + await passwordInput.type(newPassword); + + const confirmPasswordInput = await this.find.byName('confirm_password'); + await confirmPasswordInput.clearValue(); + await confirmPasswordInput.type(newPassword); + + await this.testSubjects.click('changePasswordFormSubmitButton'); + + const toast = await this.testSubjects.find('euiToastHeader'); + const title = await toast.getVisibleText(); + 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 508fb7106948a..3f4dc6056d8d2 100644 --- a/x-pack/test/functional/page_objects/security_page.ts +++ b/x-pack/test/functional/page_objects/security_page.ts @@ -497,7 +497,7 @@ export class SecurityPageObject extends FtrService { 'editUserChangePasswordConfirmPasswordInput', user.confirm_password ?? '' ); - await this.testSubjects.click('formFlyoutSubmitButton'); + await this.testSubjects.click('changePasswordFormSubmitButton'); } async updateUserProfile(user: UserFormValues) { diff --git a/yarn.lock b/yarn.lock index 35c60d9444f32..bd1dbe6f6fc54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12280,7 +12280,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== @@ -14844,6 +14844,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" @@ -19162,7 +19175,7 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash-es@^4.17.11: +lodash-es@^4.17.11, 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== @@ -23637,7 +23650,7 @@ react-error-boundary@^3.1.0: dependencies: "@babel/runtime" "^7.12.5" -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== @@ -27600,7 +27613,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==