diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 3a1a8387feaa7..ac968b0d84dcc 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -116,7 +116,7 @@ pageLoadAssetSize: screenshotMode: 17856 screenshotting: 22870 searchprofiler: 67080 - security: 65433 + security: 81771 securitySolution: 66738 securitySolutionEss: 16573 securitySolutionServerless: 40000 diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts index 16d29325502e1..f2e14d1a08526 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.test.ts @@ -19,6 +19,7 @@ describe('maybeAddCloudLinks', () => { chrome: coreMock.createStart().chrome, cloud: { ...cloudMock.createStart(), isCloudEnabled: false }, docLinks: coreMock.createStart().docLinks, + uiSettingsClient: coreMock.createStart().uiSettings, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -30,12 +31,13 @@ describe('maybeAddCloudLinks', () => { security.authc.getCurrentUser.mockResolvedValue( securityMock.createMockAuthenticatedUser({ elastic_cloud_user: true }) ); - const { chrome, docLinks } = coreMock.createStart(); + const { chrome, docLinks, uiSettings } = coreMock.createStart(); maybeAddCloudLinks({ security, chrome, cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, docLinks, + uiSettingsClient: uiSettings, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -73,6 +75,79 @@ describe('maybeAddCloudLinks', () => { "label": "Organization", "order": 300, }, + Object { + "content": , + "href": "", + "iconType": "", + "label": "", + "order": 400, + }, ], ] `); @@ -101,12 +176,13 @@ describe('maybeAddCloudLinks', () => { it('when cloud enabled and it fails to fetch the user, it sets the links', async () => { const security = securityMock.createStart(); security.authc.getCurrentUser.mockRejectedValue(new Error('Something went terribly wrong')); - const { chrome, docLinks } = coreMock.createStart(); + const { chrome, docLinks, uiSettings } = coreMock.createStart(); maybeAddCloudLinks({ security, chrome, cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, docLinks, + uiSettingsClient: uiSettings, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); @@ -144,6 +220,79 @@ describe('maybeAddCloudLinks', () => { "label": "Organization", "order": 300, }, + Object { + "content": , + "href": "", + "iconType": "", + "label": "", + "order": 400, + }, ], ] `); @@ -173,12 +322,13 @@ describe('maybeAddCloudLinks', () => { security.authc.getCurrentUser.mockResolvedValue( securityMock.createMockAuthenticatedUser({ elastic_cloud_user: false }) ); - const { chrome, docLinks } = coreMock.createStart(); + const { chrome, docLinks, uiSettings } = coreMock.createStart(); maybeAddCloudLinks({ security, chrome, cloud: { ...cloudMock.createStart(), isCloudEnabled: true }, docLinks, + uiSettingsClient: uiSettings, }); // Since there's a promise, let's wait for the next tick await new Promise((resolve) => process.nextTick(resolve)); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts index 3d7aa271ed866..1becd2cdf7254 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/maybe_add_cloud_links.ts @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import type { CloudStart } from '@kbn/cloud-plugin/public'; import type { ChromeStart } from '@kbn/core/public'; import type { SecurityPluginStart } from '@kbn/security-plugin/public'; - +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { createUserMenuLinks } from './user_menu_links'; import { createHelpMenuLinks } from './help_menu_links'; @@ -21,6 +21,7 @@ export interface MaybeAddCloudLinksDeps { chrome: ChromeStart; cloud: CloudStart; docLinks: DocLinksStart; + uiSettingsClient: IUiSettingsClient; } export function maybeAddCloudLinks({ @@ -28,6 +29,7 @@ export function maybeAddCloudLinks({ chrome, cloud, docLinks, + uiSettingsClient, }: MaybeAddCloudLinksDeps): void { const userObservable = defer(() => security.authc.getCurrentUser()).pipe( // Check if user is a cloud user. @@ -45,7 +47,7 @@ export function maybeAddCloudLinks({ href: cloud.deploymentUrl, }); } - const userMenuLinks = createUserMenuLinks(cloud); + const userMenuLinks = createUserMenuLinks({ cloud, security, uiSettingsClient }); security.navControlService.addUserMenuLinks(userMenuLinks); }) ); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts new file mode 100644 index 0000000000000..75d6ae4d1d329 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_hook.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +interface Deps { + uiSettingsClient: IUiSettingsClient; + security: SecurityPluginStart; +} + +export const useThemeDarkmodeToggle = ({ uiSettingsClient, security }: Deps) => { + const [isDarkModeOn, setIsDarkModeOn] = useState(false); + // If a value is set in kibana.yml (uiSettings.overrides.theme:darkMode) + // we don't allow the user to change the theme color. + const valueSetInKibanaConfig = uiSettingsClient.isOverridden('theme:darkMode'); + + const { userProfileData, isLoading, update } = security.hooks.useUpdateUserProfile({ + notificationSuccess: { + title: i18n.translate('xpack.cloudLinks.userMenuLinks.darkMode.successNotificationTitle', { + defaultMessage: 'Color theme updated', + }), + pageReloadText: i18n.translate( + 'xpack.cloudLinks.userMenuLinks.darkMode.successNotificationText', + { + defaultMessage: 'Reload the page to see the changes', + } + ), + }, + pageReloadChecker: (prev, next) => { + return prev?.userSettings?.darkMode !== next.userSettings?.darkMode; + }, + }); + + const { userSettings: { darkMode: colorScheme } = { darkMode: undefined } } = + userProfileData ?? {}; + + const toggle = useCallback( + (on: boolean) => { + if (isLoading) { + return; + } + update({ + userSettings: { + darkMode: on ? 'dark' : 'light', + }, + }); + }, + [isLoading, update] + ); + + useEffect(() => { + let updatedValue = false; + + if (typeof colorScheme !== 'string') { + // User profile does not have yet any preference -> default to space dark mode value + updatedValue = uiSettingsClient.get('theme:darkMode') ?? false; + } else { + updatedValue = colorScheme === 'dark'; + } + + setIsDarkModeOn(updatedValue); + }, [colorScheme, uiSettingsClient]); + + return { + isVisible: valueSetInKibanaConfig ? false : Boolean(userProfileData), + toggle, + isDarkModeOn, + colorScheme, + }; +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx new file mode 100644 index 0000000000000..2bebdad488498 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { coreMock } from '@kbn/core/public/mocks'; +import { securityMock } from '@kbn/security-plugin/public/mocks'; + +import { ThemDarkModeToggle } from './theme_darkmode_toggle'; + +describe('ThemDarkModeToggle', () => { + const mockUseUpdateUserProfile = jest.fn(); + const mockGetSpaceDarkModeValue = jest.fn(); + + it('renders correctly and toggles dark mode', () => { + const security = { + ...securityMock.createStart(), + hooks: { useUpdateUserProfile: mockUseUpdateUserProfile }, + }; + const { uiSettings } = coreMock.createStart(); + + const mockUpdate = jest.fn(); + mockUseUpdateUserProfile.mockReturnValue({ + userProfileData: { userSettings: { darkMode: 'light' } }, + isLoading: false, + update: mockUpdate, + }); + + mockGetSpaceDarkModeValue.mockReturnValue(false); + + const { getByTestId, rerender } = render( + + ); + + const toggleSwitch = getByTestId('darkModeToggleSwitch'); + fireEvent.click(toggleSwitch); + expect(mockUpdate).toHaveBeenCalledWith({ userSettings: { darkMode: 'dark' } }); + + // Now we want to simulate toggling back to light + mockUseUpdateUserProfile.mockReturnValue({ + userProfileData: { userSettings: { darkMode: 'dark' } }, + isLoading: false, + update: mockUpdate, + }); + + // Rerender the component to apply the new props + rerender(); + + fireEvent.click(toggleSwitch); + expect(mockUpdate).toHaveBeenLastCalledWith({ userSettings: { darkMode: 'light' } }); + }); +}); diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx new file mode 100644 index 0000000000000..3f85c2ed63a76 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/theme_darkmode_toggle.tsx @@ -0,0 +1,80 @@ +/* + * 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 React from 'react'; +import { + EuiContextMenuItem, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + useEuiTheme, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { SecurityPluginStart } from '@kbn/security-plugin/public'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { useThemeDarkmodeToggle } from './theme_darkmode_hook'; + +interface Props { + uiSettingsClient: IUiSettingsClient; + security: SecurityPluginStart; +} + +export const ThemDarkModeToggle = ({ security, uiSettingsClient }: Props) => { + const toggleTextSwitchId = useGeneratedHtmlId({ prefix: 'toggleTextSwitch' }); + const { euiTheme } = useEuiTheme(); + + const { isVisible, toggle, isDarkModeOn, colorScheme } = useThemeDarkmodeToggle({ + security, + uiSettingsClient, + }); + + if (!isVisible) { + return null; + } + + return ( + + + { + const on = colorScheme === 'light' ? true : false; + toggle(on); + }} + data-test-subj="darkModeToggle" + > + {i18n.translate('xpack.cloudLinks.userMenuLinks.darkModeToggle', { + defaultMessage: 'Dark mode', + })} + + + + { + toggle(e.target.checked); + }} + aria-describedby={toggleTextSwitchId} + data-test-subj="darkModeToggleSwitch" + compressed + /> + + + ); +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.ts b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx similarity index 67% rename from x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.ts rename to x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx index b6996a2f8f5a2..1eae5d6ed0c58 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.ts +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/maybe_add_cloud_links/user_menu_links.tsx @@ -5,11 +5,22 @@ * 2.0. */ +import React from 'react'; import { i18n } from '@kbn/i18n'; import type { CloudStart } from '@kbn/cloud-plugin/public'; -import type { UserMenuLink } from '@kbn/security-plugin/public'; +import type { SecurityPluginStart, UserMenuLink } from '@kbn/security-plugin/public'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { ThemDarkModeToggle } from './theme_darkmode_toggle'; -export const createUserMenuLinks = (cloud: CloudStart): UserMenuLink[] => { +export const createUserMenuLinks = ({ + cloud, + security, + uiSettingsClient, +}: { + cloud: CloudStart; + security: SecurityPluginStart; + uiSettingsClient: IUiSettingsClient; +}): UserMenuLink[] => { const { profileUrl, billingUrl, organizationUrl } = cloud; const userMenuLinks = [] as UserMenuLink[]; @@ -48,5 +59,13 @@ export const createUserMenuLinks = (cloud: CloudStart): UserMenuLink[] => { }); } + userMenuLinks.push({ + content: , + order: 400, + label: '', + iconType: '', + href: '', + }); + return userMenuLinks; }; diff --git a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx index 1eb490ba0cd3a..7ee3f0969251d 100755 --- a/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_links/public/plugin.tsx @@ -43,7 +43,13 @@ export class CloudLinksPlugin }); } if (security) { - maybeAddCloudLinks({ security, chrome: core.chrome, cloud, docLinks: core.docLinks }); + maybeAddCloudLinks({ + security, + chrome: core.chrome, + cloud, + docLinks: core.docLinks, + uiSettingsClient: core.uiSettings, + }); } } } diff --git a/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json b/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json index a57da9edc3199..0dfa7ce42858d 100644 --- a/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json +++ b/x-pack/plugins/cloud_integrations/cloud_links/tsconfig.json @@ -19,6 +19,7 @@ "@kbn/guided-onboarding-plugin", "@kbn/core-chrome-browser", "@kbn/core-doc-links-browser", + "@kbn/core-ui-settings-browser", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/security/common/model/user_profile.ts b/x-pack/plugins/security/common/model/user_profile.ts index a39f56a4bff96..c4dd6addd51fc 100644 --- a/x-pack/plugins/security/common/model/user_profile.ts +++ b/x-pack/plugins/security/common/model/user_profile.ts @@ -90,11 +90,13 @@ export interface UserProfileAvatarData { imageUrl?: string | null; } +export type DarkModeValue = '' | 'dark' | 'light'; + /** * User settings stored in the data object of the User Profile */ export interface UserSettingsData { - darkMode?: string; + darkMode?: DarkModeValue; } /** diff --git a/x-pack/plugins/security/public/account_management/index.ts b/x-pack/plugins/security/public/account_management/index.ts index e1a4957aa71e7..eca7287537318 100644 --- a/x-pack/plugins/security/public/account_management/index.ts +++ b/x-pack/plugins/security/public/account_management/index.ts @@ -11,4 +11,5 @@ export type { UserProfileBulkGetParams, UserProfileGetCurrentParams, UserProfileSuggestParams, + UpdateUserProfileHook, } from './user_profile'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/index.ts b/x-pack/plugins/security/public/account_management/user_profile/index.ts index ed34d7d4a4339..93a1c7d04d315 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/index.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/index.ts @@ -13,3 +13,5 @@ export type { UserProfileBulkGetParams, UserProfileSuggestParams, } from './user_profile_api_client'; + +export type { UpdateUserProfileHook } from './use_update_user_profile'; diff --git a/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.test.tsx b/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.test.tsx new file mode 100644 index 0000000000000..6690e9b6cf946 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 { BehaviorSubject, first, lastValueFrom } from 'rxjs'; + +import { coreMock } from '@kbn/core/public/mocks'; + +import { getUseUpdateUserProfile } from './use_update_user_profile'; +import { UserProfileAPIClient } from './user_profile_api_client'; + +const { notifications, http } = coreMock.createStart(); +const userProfileApiClient = new UserProfileAPIClient(http); +const useUpdateUserProfile = getUseUpdateUserProfile({ + apiClient: userProfileApiClient, + notifications, +}); + +describe('useUpdateUserProfile', () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(userProfileApiClient, 'update'); + http.get.mockReset(); + http.post.mockReset().mockResolvedValue(undefined); + notifications.toasts.addSuccess.mockReset(); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + test('should call the apiClient with the updated user profile data', async () => { + const { result } = renderHook(() => useUpdateUserProfile()); + const { update } = result.current; + + await act(async () => { + update({ userSettings: { darkMode: 'dark' } }); + }); + + expect(spy).toHaveBeenCalledWith({ userSettings: { darkMode: 'dark' } }); + }); + + test('should update the isLoading state while updating', async () => { + const { result, waitForNextUpdate } = renderHook(() => useUpdateUserProfile()); + const { update } = result.current; + const httpPostDone = new BehaviorSubject(false); + + http.post.mockImplementationOnce(async () => { + await lastValueFrom(httpPostDone.pipe(first((v) => v === true))); + }); + + expect(result.current.isLoading).toBeFalsy(); + + await act(async () => { + update({ userSettings: { darkMode: 'dark' } }); + }); + + expect(result.current.isLoading).toBeTruthy(); + + httpPostDone.next(true); // Resolve the http.post promise + await waitForNextUpdate(); + + expect(result.current.isLoading).toBeFalsy(); + }); + + test('should show a success notification by default', async () => { + const { result } = renderHook(() => useUpdateUserProfile()); + const { update } = result.current; + + await act(async () => { + await update({ userSettings: { darkMode: 'dark' } }); + }); + + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith( + { + title: 'Profile updated', + }, + {} // toast options + ); + }); + + test('should show a notification with reload page button when refresh is required', async () => { + const pageReloadChecker = () => { + return true; + }; + + const { result } = renderHook(() => + useUpdateUserProfile({ + pageReloadChecker, + }) + ); + const { update } = result.current; + + await act(async () => { + await update({ userSettings: { darkMode: 'dark' } }); + }); + + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith( + { + title: 'Profile updated', + text: expect.any(Function), // React node + }, + { + toastLifeTimeMs: 300000, // toast options + } + ); + }); + + test('should pass the previous and next user profile data to the pageReloadChecker', async () => { + const pageReloadChecker = jest.fn(); + + const initialValue = { foo: 'bar' }; + http.get.mockReset().mockResolvedValue({ data: initialValue }); + const userProfileApiClient2 = new UserProfileAPIClient(http); + await userProfileApiClient2.getCurrent(); // Sets the initial value of the userProfile$ Observable + + const { result } = renderHook(() => + getUseUpdateUserProfile({ + apiClient: userProfileApiClient2, + notifications, + })({ + pageReloadChecker, + }) + ); + const { update } = result.current; + + const nextValue = { userSettings: { darkMode: 'light' as const } }; + await act(async () => { + await update(nextValue); + }); + + expect(pageReloadChecker).toHaveBeenCalledWith(initialValue, nextValue); + }); +}); diff --git a/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.tsx new file mode 100644 index 0000000000000..2dafa61496fe8 --- /dev/null +++ b/x-pack/plugins/security/public/account_management/user_profile/use_update_user_profile.tsx @@ -0,0 +1,150 @@ +/* + * 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, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback, useRef, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + +import type { NotificationsStart, ToastInput, ToastOptions } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { toMountPoint } from '@kbn/kibana-react-plugin/public'; + +import type { UserProfileData } from './user_profile'; +import type { UserProfileAPIClient } from './user_profile_api_client'; + +interface Deps { + apiClient: UserProfileAPIClient; + notifications: NotificationsStart; +} + +interface Props { + notificationSuccess?: { + /** Flag to indicate if a notification is shown after update. Default: `true` */ + enabled?: boolean; + /** Customize the title of the notification */ + title?: string; + /** Customize the "page reload needed" text of the notification */ + pageReloadText?: string; + }; + /** Predicate to indicate if the update requires a page reload */ + pageReloadChecker?: ( + previsous: UserProfileData | null | undefined, + next: UserProfileData + ) => boolean; +} + +export type UpdateUserProfileHook = (props?: Props) => { + /** Update the user profile */ + update: (data: UserProfileData) => void; + /** Handler to show a notification after the user profile has been updated */ + showSuccessNotification: (props: { isRefreshRequired: boolean }) => void; + /** Flag to indicate if currently updating */ + isLoading: boolean; + /** The current user profile data */ + userProfileData?: UserProfileData | null; +}; + +const i18nTexts = { + notificationSuccess: { + title: i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { + defaultMessage: 'Profile updated', + }), + pageReloadText: i18n.translate( + 'xpack.security.accountManagement.userProfile.requiresPageReloadToastDescription', + { + defaultMessage: 'One or more settings require you to reload the page to take effect.', + } + ), + }, +}; + +export const getUseUpdateUserProfile = ({ apiClient, notifications }: Deps) => { + const { userProfile$ } = apiClient; + + const useUpdateUserProfile = ({ notificationSuccess = {}, pageReloadChecker }: Props = {}) => { + const { + enabled: notificationSuccessEnabled = true, + title: notificationTitle = i18nTexts.notificationSuccess.title, + pageReloadText = i18nTexts.notificationSuccess.pageReloadText, + } = notificationSuccess; + const [isLoading, setIsLoading] = useState(false); + const userProfileData = useObservable(userProfile$); + // Keep a snapshot before updating the user profile so we can compare previous and updated values + const userProfileSnapshot = useRef(); + + const showSuccessNotification = useCallback( + ({ isRefreshRequired = false }: { isRefreshRequired?: boolean } = {}) => { + let successToastInput: ToastInput = { + title: notificationTitle, + }; + let successToastOptions: ToastOptions = {}; + + if (isRefreshRequired) { + successToastOptions = { + toastLifeTimeMs: 1000 * 60 * 5, + }; + + successToastInput = { + ...successToastInput, + text: toMountPoint( + + +

{pageReloadText}

+ window.location.reload()} + data-test-subj="windowReloadButton" + > + {i18n.translate( + 'xpack.security.accountManagement.userProfile.requiresPageReloadToastButtonLabel', + { + defaultMessage: 'Reload page', + } + )} + +
+
+ ), + }; + } + + notifications.toasts.addSuccess(successToastInput, successToastOptions); + }, + [notificationTitle, pageReloadText] + ); + + const onUserProfileUpdate = useCallback( + (updatedData: UserProfileData) => { + setIsLoading(false); + + if (notificationSuccessEnabled) { + const isRefreshRequired = pageReloadChecker?.(userProfileSnapshot.current, updatedData); + showSuccessNotification({ isRefreshRequired }); + } + }, + [notificationSuccessEnabled, showSuccessNotification, pageReloadChecker] + ); + + const update = useCallback( + (udpatedData: D) => { + userProfileSnapshot.current = userProfileData; + setIsLoading(true); + return apiClient.update(udpatedData).then(() => onUserProfileUpdate(udpatedData)); + }, + [onUserProfileUpdate, userProfileData] + ); + + return { + update, + showSuccessNotification, + userProfileData, + isLoading, + }; + }; + + return useUpdateUserProfile; +}; diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx index fd7de9a7047f3..e9bb4b23af997 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile.tsx @@ -34,20 +34,24 @@ import type { FunctionComponent } from 'react'; import React, { useRef, useState } from 'react'; import useUpdateEffect from 'react-use/lib/useUpdateEffect'; -import type { CoreStart, IUiSettingsClient, ToastInput, ToastOptions } from '@kbn/core/public'; +import type { CoreStart, IUiSettingsClient } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { toMountPoint, useKibana } from '@kbn/kibana-react-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { UserAvatar } from '@kbn/user-profile-components'; -import type { AuthenticatedUser, UserProfileAvatarData } from '../../../common'; +import type { AuthenticatedUser } from '../../../common'; import { canUserChangeDetails, canUserChangePassword, getUserAvatarColor, getUserAvatarInitials, } from '../../../common/model'; -import type { UserSettingsData } from '../../../common/model/user_profile'; +import type { + DarkModeValue, + UserProfileAvatarData, + UserSettingsData, +} from '../../../common/model/user_profile'; import { useSecurityApiClients } from '../../components'; import { Breadcrumb } from '../../components/breadcrumb'; import { @@ -60,14 +64,18 @@ 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 { getUseUpdateUserProfile } from './use_update_user_profile'; import { createImageHandler, getRandomColor, IMAGE_FILE_TYPES, VALID_HEX_COLOR } from './utils'; +export interface UserProfileData { + avatar?: UserProfileAvatarData; + userSettings?: UserSettingsData; + [key: string]: unknown; +} + export interface UserProfileProps { user: AuthenticatedUser; - data?: { - avatar?: UserProfileAvatarData; - userSettings?: UserSettingsData; - }; + data?: UserProfileData; } export interface UserDetailsEditorProps { @@ -96,7 +104,7 @@ export interface UserProfileFormValues { imageUrl: string; }; userSettings: { - darkMode: string; + darkMode: DarkModeValue; }; }; avatarType: 'initials' | 'image'; @@ -815,6 +823,11 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { const { services } = useKibana(); const { userProfiles, users } = useSecurityApiClients(); + const { update, showSuccessNotification } = getUseUpdateUserProfile({ + apiClient: userProfiles, + notifications: services.notifications, + })({ notificationSuccess: { enabled: false } }); + const [initialValues, resetInitialValues] = useState({ user: { full_name: user.full_name || '', @@ -855,7 +868,7 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { // Update profile only if it's available for the current user. if (values.data) { submitActions.push( - userProfiles.update( + update( values.avatarType === 'image' ? values.data : { ...values.data, avatar: { ...values.data.avatar, imageUrl: null } } @@ -878,59 +891,13 @@ export function useUserProfileForm({ user, data }: UserProfileProps) { return; } + resetInitialValues(values); + let isRefreshRequired = false; if (initialValues.data?.userSettings.darkMode !== values.data?.userSettings.darkMode) { isRefreshRequired = true; } - - resetInitialValues(values); - - let successToastInput: ToastInput = { - title: i18n.translate('xpack.security.accountManagement.userProfile.submitSuccessTitle', { - defaultMessage: 'Profile updated', - }), - }; - - let successToastOptions: ToastOptions = {}; - - if (isRefreshRequired) { - successToastOptions = { - toastLifeTimeMs: 1000 * 60 * 5, - }; - - successToastInput = { - ...successToastInput, - text: toMountPoint( - - -

- {i18n.translate( - 'xpack.security.accountManagement.userProfile.requiresPageReloadToastDescription', - { - defaultMessage: - 'One or more settings require you to reload the page to take effect.', - } - )} -

- window.location.reload()} - data-test-subj="windowReloadButton" - > - {i18n.translate( - 'xpack.security.accountManagement.userProfile.requiresPageReloadToastButtonLabel', - { - defaultMessage: 'Reload page', - } - )} - -
-
- ), - }; - } - - services.notifications.toasts.addSuccess(successToastInput, successToastOptions); + showSuccessNotification({ isRefreshRequired }); }, initialValues, enableReinitialize: true, diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts index 4fdb482a2cbae..0c275db2c1a90 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.test.ts @@ -14,6 +14,7 @@ describe('UserProfileAPIClient', () => { let apiClient: UserProfileAPIClient; beforeEach(() => { coreStart = coreMock.createStart(); + coreStart.http.get.mockResolvedValue(undefined); coreStart.http.post.mockResolvedValue(undefined); apiClient = new UserProfileAPIClient(coreStart.http); diff --git a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts index a2fc60f7b4d75..4b992f616ca14 100644 --- a/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts +++ b/x-pack/plugins/security/public/account_management/user_profile/user_profile_api_client.ts @@ -5,12 +5,14 @@ * 2.0. */ +import { merge } from 'lodash'; import type { Observable } from 'rxjs'; -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import type { HttpStart } from '@kbn/core/public'; -import type { GetUserProfileResponse, UserProfile, UserProfileData } from '../../../common'; +import type { GetUserProfileResponse, UserProfile } from '../../../common'; +import type { UserProfileData } from './user_profile'; /** * Parameters for the get user profile for the current user API. @@ -70,6 +72,11 @@ export class UserProfileAPIClient { public readonly dataUpdates$: Observable = this.internalDataUpdates$.asObservable(); + private readonly _userProfile$ = new BehaviorSubject(null); + + /** Observable of the current user profile data */ + public readonly userProfile$ = this._userProfile$.asObservable(); + constructor(private readonly http: HttpStart) {} /** @@ -80,9 +87,16 @@ export class UserProfileAPIClient { * optional "dataPath" parameter can be used to return personal data for this user. */ public getCurrent(params?: UserProfileGetCurrentParams) { - return this.http.get>('/internal/security/user_profile', { - query: { dataPath: params?.dataPath }, - }); + return this.http + .get>('/internal/security/user_profile', { + query: { dataPath: params?.dataPath }, + }) + .then((response) => { + const data = response?.data ?? {}; + const updated = merge(this._userProfile$.getValue(), data); + this._userProfile$.next(updated); + return response; + }); } /** @@ -126,10 +140,19 @@ export class UserProfileAPIClient { * @param data Application data to be written (merged with existing data). */ public update(data: D) { + // Optimistic update the user profile Observable. + const previous = this._userProfile$.getValue(); + this._userProfile$.next(data); + return this.http .post('/internal/security/user_profile/_data', { body: JSON.stringify(data) }) .then(() => { this.internalDataUpdates$.next(data); + }) + .catch((err) => { + // Revert the user profile data to the previous state. + this._userProfile$.next(previous); + return Promise.reject(err); }); } } diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 209bc5ff576b6..b51bb3d25092e 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -24,6 +24,7 @@ export type { UserProfileBulkGetParams, UserProfileGetCurrentParams, UserProfileSuggestParams, + UpdateUserProfileHook, } from './account_management'; export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication'; diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index e776d55f17d06..f0081307ef33f 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { of } from 'rxjs'; + import { licenseMock } from '../common/licensing/index.mock'; import type { MockAuthenticatedUserProps } from '../common/model/authenticated_user.mock'; import { mockAuthenticatedUser } from '../common/model/authenticated_user.mock'; @@ -22,8 +24,17 @@ function createStartMock() { return { authc: authenticationMock.createStart(), navControlService: navControlServiceMock.createStart(), - userProfiles: { getCurrent: jest.fn(), bulkGet: jest.fn(), suggest: jest.fn() }, + userProfiles: { + getCurrent: jest.fn(), + bulkGet: jest.fn(), + suggest: jest.fn(), + update: jest.fn(), + userProfile$: of({}), + }, uiApi: getUiApiMock.createStart(), + hooks: { + useUpdateUserProfile: jest.fn(), + }, }; } 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 2936f705b75a2..a0f9df820c91e 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 @@ -166,6 +166,8 @@ describe('SecurityNavControl', () => { }); it('should render additional user menu links registered by other plugins and should render the default Edit Profile link as the first link when no custom profile link is provided', async () => { + const DummyComponent = () =>
Dummy Component
; + const wrapper = shallow( { { 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 }, + { + label: 'dummyComponent', + href: '', + iconType: 'empty', + order: 4, + content: DummyComponent, + }, ]) } /> @@ -183,63 +192,80 @@ describe('SecurityNavControl', () => { expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` Array [ Object { + "content": , + "name": , + "onClick": [Function], + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link1", + "href": "path-to-link-1", + "icon": , + "name": "link1", + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "content": [Function], + "data-test-subj": "userMenuLink__dummyComponent", + "href": "", + "icon": , + "name": "dummyComponent", + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ] + } + />, "id": 0, - "items": Array [ - Object { - "data-test-subj": "profileLink", - "href": "edit-profile-link", - "icon": , - "name": , - "onClick": [Function], - }, - 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", }, ] @@ -270,49 +296,56 @@ describe('SecurityNavControl', () => { expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` Array [ Object { + "content": , + "name": "link1", + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link2", + "href": "path-to-link-2", + "icon": , + "name": "link2", + }, + Object { + "content": undefined, + "data-test-subj": "userMenuLink__link3", + "href": "path-to-link-3", + "icon": , + "name": "link3", + }, + Object { + "data-test-subj": "logoutLink", + "href": "", + "icon": , + "name": , + }, + ] + } + />, "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": "logoutLink", - "href": "", - "icon": , - "name": , - }, - ], "title": "full name", }, ] @@ -340,22 +373,26 @@ describe('SecurityNavControl', () => { expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(` Array [ Object { + "content": , + "name": , + }, + ] + } + />, "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 03f162c28dcf7..4a5e8ad545d64 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 @@ -8,13 +8,15 @@ import type { EuiContextMenuPanelItemDescriptor, IconType } from '@elastic/eui'; import { EuiContextMenu, + EuiContextMenuItem, + EuiContextMenuPanel, EuiHeaderSectionItemButton, EuiIcon, EuiLoadingSpinner, EuiPopover, } from '@elastic/eui'; -import type { FunctionComponent } from 'react'; -import React, { useState } from 'react'; +import type { FunctionComponent, ReactNode } from 'react'; +import React, { Fragment, useState } from 'react'; import useObservable from 'react-use/lib/useObservable'; import type { Observable } from 'rxjs'; @@ -32,8 +34,41 @@ export interface UserMenuLink { href: string; order?: number; setAsProfile?: boolean; + /** Render a custom ReactNode instead of the default */ + content?: ReactNode; } +type ContextMenuItem = EuiContextMenuPanelItemDescriptor & { content?: ReactNode }; + +interface ContextMenuProps { + items: ContextMenuItem[]; +} + +const ContextMenuContent = ({ items }: ContextMenuProps) => { + return ( + <> + + {items.map((item, i) => { + if (item.content) { + return {item.content}; + } + return ( + + {item.name} + + ); + })} + + + ); +}; + interface SecurityNavControlProps { editProfileUrl: string; logoutUrl: string; @@ -48,7 +83,7 @@ export const SecurityNavControl: FunctionComponent = ({ const userMenuLinks = useObservable(userMenuLinks$, []); const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar'); + const userProfile = useUserProfile<{ avatar: UserProfileAvatarData }>('avatar,userSettings'); 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) : ''; @@ -80,15 +115,16 @@ export const SecurityNavControl: FunctionComponent = ({ ); - const items: EuiContextMenuPanelItemDescriptor[] = []; + const items: ContextMenuItem[] = []; if (userMenuLinks.length) { const userMenuLinkMenuItems = userMenuLinks .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) - .map(({ label, iconType, href }: UserMenuLink) => ({ + .map(({ label, iconType, href, content }: UserMenuLink) => ({ name: label, icon: , href, 'data-test-subj': `userMenuLink__${label}`, + content, })); items.push(...userMenuLinkMenuItems); } @@ -153,7 +189,7 @@ export const SecurityNavControl: FunctionComponent = ({ { id: 0, title: displayName, - items, + content: , }, ]} /> diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index a63216fb93465..87ce15a19202d 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -96,6 +96,9 @@ describe('Security Plugin', () => { "areAPIKeysEnabled": [Function], "getCurrentUser": [Function], }, + "hooks": Object { + "useUpdateUserProfile": [Function], + }, "navControlService": Object { "addUserMenuLinks": [Function], "getUserMenuLinks$": [Function], @@ -110,6 +113,18 @@ describe('Security Plugin', () => { "bulkGet": [Function], "getCurrent": [Function], "suggest": [Function], + "update": [Function], + "userProfile$": Observable { + "source": BehaviorSubject { + "_value": null, + "closed": false, + "currentObservers": null, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, }, } `); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 7928317ba4a77..631b7341fc3b3 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -24,7 +24,9 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { SecurityLicense } from '../common/licensing'; import { SecurityLicenseService } from '../common/licensing'; +import type { UpdateUserProfileHook } from './account_management'; import { accountManagementApp, UserProfileAPIClient } from './account_management'; +import { getUseUpdateUserProfile } from './account_management/user_profile/use_update_user_profile'; import { AnalyticsService } from './analytics'; import { AnonymousAccessService } from './anonymous_access'; import type { AuthenticationServiceSetup, AuthenticationServiceStart } from './authentication'; @@ -207,6 +209,16 @@ export class SecurityPlugin suggest: this.securityApiClients.userProfiles.suggest.bind( this.securityApiClients.userProfiles ), + update: this.securityApiClients.userProfiles.update.bind( + this.securityApiClients.userProfiles + ), + userProfile$: this.securityApiClients.userProfiles.userProfile$, + }, + hooks: { + useUpdateUserProfile: getUseUpdateUserProfile({ + apiClient: this.securityApiClients.userProfiles, + notifications: core.notifications, + }), }, }; } @@ -247,7 +259,17 @@ export interface SecurityPluginStart { /** * A set of methods to work with Kibana user profiles. */ - userProfiles: Pick; + userProfiles: Pick< + UserProfileAPIClient, + 'getCurrent' | 'bulkGet' | 'suggest' | 'update' | 'userProfile$' + >; + + /** + * A set of hooks to work with Kibana user profiles + */ + hooks: { + useUpdateUserProfile: UpdateUserProfileHook; + }; /** * Exposes UI components that will be loaded asynchronously. diff --git a/x-pack/plugins/security/server/lib/flatten_object.test.ts b/x-pack/plugins/security/server/lib/flatten_object.test.ts new file mode 100644 index 0000000000000..a69c7401154a1 --- /dev/null +++ b/x-pack/plugins/security/server/lib/flatten_object.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flattenObject } from './flatten_object'; + +describe('FlattenObject', () => { + it('flattens multi level item', () => { + const data = { + foo: { + item1: 'value 1', + item2: { itemA: 'value 2' }, + }, + bar: { + item3: { itemA: { itemAB: 'value AB' } }, + item4: 'value 4', + item5: [1], + item6: [1, 2, 3], + }, + }; + + const flatten = flattenObject(data); + expect(flatten).toEqual({ + 'bar.item3.itemA.itemAB': 'value AB', + 'bar.item4': 'value 4', + 'bar.item5': 1, + 'bar.item6.0': 1, + 'bar.item6.1': 2, + 'bar.item6.2': 3, + 'foo.item1': 'value 1', + 'foo.item2.itemA': 'value 2', + }); + }); + + it('returns an empty object if no valid object is provided', () => { + expect(flattenObject({})).toEqual({}); + expect(flattenObject(null)).toEqual({}); + expect(flattenObject(undefined)).toEqual({}); + }); +}); diff --git a/x-pack/plugins/security/server/lib/flatten_object.ts b/x-pack/plugins/security/server/lib/flatten_object.ts new file mode 100644 index 0000000000000..45c0a3b6bbf13 --- /dev/null +++ b/x-pack/plugins/security/server/lib/flatten_object.ts @@ -0,0 +1,35 @@ +/* + * 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 { compact, isObject } from 'lodash'; + +// Inspired by x-pack/plugins/apm/public/utils/flatten_object.ts +// Slighly modified to have key/value exposed as Object. +export const flattenObject = ( + item: Record | null | undefined, + accDefault: Record = {}, + parentKey?: string +): Record => { + if (item) { + const isArrayWithSingleValue = Array.isArray(item) && item.length === 1; + return Object.keys(item) + .sort() + .reduce>((acc, key) => { + const childKey = isArrayWithSingleValue ? '' : key; + const currentKey = compact([parentKey, childKey]).join('.'); + // item[key] can be a primitive (string, number, boolean, null, undefined) or Object or Array + if (isObject(item[key])) { + flattenObject(item[key], acc, currentKey); + } else { + acc[currentKey] = item[key]; + } + + return acc; + }, accDefault); + } + return {}; +}; diff --git a/x-pack/plugins/security/server/lib/index.ts b/x-pack/plugins/security/server/lib/index.ts index 1a1ae84e2af65..715eeb0955daa 100644 --- a/x-pack/plugins/security/server/lib/index.ts +++ b/x-pack/plugins/security/server/lib/index.ts @@ -11,3 +11,4 @@ export { validateKibanaPrivileges, transformPrivilegesToElasticsearchPrivileges, } from './role_utils'; +export { flattenObject } from './flatten_object'; diff --git a/x-pack/plugins/security/server/routes/user_profile/update.test.ts b/x-pack/plugins/security/server/routes/user_profile/update.test.ts index f358b65546d27..9165ee154cb78 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.test.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.test.ts @@ -132,6 +132,35 @@ describe('Update profile routes', () => { expect(userProfileService.update).not.toHaveBeenCalled(); }); + it('only allow specific user profile data keys to be updated for Elastic Cloud users.', async () => { + session.get.mockResolvedValue({ + error: null, + value: sessionMock.createValue({ userProfileId: 'u_some_id' }), + }); + authc.getCurrentUser.mockReturnValue(mockAuthenticatedUser({ elastic_cloud_user: true })); + + await expect( + routeHandler( + getMockContext(), + httpServerMock.createKibanaRequest({ + body: { + userSettings: { + darkMode: 'dark', // "userSettings.darkMode" is allowed + }, + }, + }), + kibanaResponseFactory + ) + ).resolves.toEqual(expect.objectContaining({ status: 200, payload: undefined })); + + expect(userProfileService.update).toBeCalledTimes(1); + expect(userProfileService.update).toBeCalledWith('u_some_id', { + userSettings: { + darkMode: 'dark', + }, + }); + }); + it('updates profile.', async () => { session.get.mockResolvedValue({ error: null, 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 46df35eec036c..205f6a6d68a4a 100644 --- a/x-pack/plugins/security/server/routes/user_profile/update.ts +++ b/x-pack/plugins/security/server/routes/user_profile/update.ts @@ -9,9 +9,13 @@ import { schema } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; import { wrapIntoCustomErrorResponse } from '../../errors'; +import { flattenObject } from '../../lib'; import { getPrintableSessionId } from '../../session_management'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +/** User profile data keys that are allowed to be updated by Cloud users */ +const ALLOWED_KEYS_UPDATE_CLOUD = ['userSettings.darkMode']; + export function defineUpdateUserProfileDataRoute({ router, getSession, @@ -43,18 +47,27 @@ export function defineUpdateUserProfileDataRoute({ } const currentUser = getAuthenticationService().getCurrentUser(request); + const userProfileData = request.body; + const keysToUpdate = Object.keys(flattenObject(userProfileData)); + if (currentUser?.elastic_cloud_user) { - logger.warn( - `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${getPrintableSessionId( - session.value.sid - )})` + // We only allow specific user profile data to be updated by Elastic Cloud SSO users. + const isUpdateAllowed = keysToUpdate.every((key) => + ALLOWED_KEYS_UPDATE_CLOUD.includes(key) ); - return response.forbidden(); + if (keysToUpdate.length === 0 || !isUpdateAllowed) { + logger.warn( + `Elastic Cloud SSO users aren't allowed to update profiles in Kibana. (sid: ${getPrintableSessionId( + session.value.sid + )})` + ); + return response.forbidden(); + } } const userProfileService = getUserProfileService(); try { - await userProfileService.update(session.value.userProfileId, request.body); + await userProfileService.update(session.value.userProfileId, userProfileData); return response.ok(); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); diff --git a/x-pack/test/functional_cloud/tests/cloud_links.ts b/x-pack/test/functional_cloud/tests/cloud_links.ts index 743df23740fb4..873cd943ec59d 100644 --- a/x-pack/test/functional_cloud/tests/cloud_links.ts +++ b/x-pack/test/functional_cloud/tests/cloud_links.ts @@ -72,6 +72,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const cloudLink = await find.byLinkText('Organization'); expect(cloudLink).to.not.be(null); }); + + it('Shows the theme darkMode toggle', async () => { + await PageObjects.common.clickAndValidate('userMenuButton', 'darkModeToggle'); + const darkModeSwitch = await find.byCssSelector('[data-test-subj="darkModeToggleSwitch"]'); + expect(darkModeSwitch).to.not.be(null); + }); }); }); }