[ColorScheme] Update from avatar menu (#161214)

This commit is contained in:
Sébastien Loix 2023-07-12 15:46:41 +01:00 committed by GitHub
parent d166193ac0
commit 3a434bfe85
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 1126 additions and 201 deletions

View file

@ -116,7 +116,7 @@ pageLoadAssetSize:
screenshotMode: 17856
screenshotting: 22870
searchprofiler: 67080
security: 65433
security: 81771
securitySolution: 66738
securitySolutionEss: 16573
securitySolutionServerless: 40000

View file

@ -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": <ThemDarkModeToggle
security={
Object {
"authc": Object {
"areAPIKeysEnabled": [MockFunction],
"getCurrentUser": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
},
"hooks": Object {
"useUpdateUserProfile": [MockFunction],
},
"navControlService": Object {
"addUserMenuLinks": [MockFunction] {
"calls": Array [
[Circular],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"getUserMenuLinks$": [MockFunction],
},
"uiApi": Object {
"components": Object {
"getChangePassword": [MockFunction],
"getPersonalInfo": [MockFunction],
},
},
"userProfiles": Object {
"bulkGet": [MockFunction],
"getCurrent": [MockFunction],
"suggest": [MockFunction],
"update": [MockFunction],
"userProfile$": Observable {
"_subscribe": [Function],
},
},
}
}
uiSettingsClient={
Object {
"get": [MockFunction],
"get$": [MockFunction],
"getAll": [MockFunction],
"getUpdate$": [MockFunction],
"getUpdateErrors$": [MockFunction],
"isCustom": [MockFunction],
"isDeclared": [MockFunction],
"isDefault": [MockFunction],
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
}
}
/>,
"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": <ThemDarkModeToggle
security={
Object {
"authc": Object {
"areAPIKeysEnabled": [MockFunction],
"getCurrentUser": [MockFunction] {
"calls": Array [
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Promise {},
},
],
},
},
"hooks": Object {
"useUpdateUserProfile": [MockFunction],
},
"navControlService": Object {
"addUserMenuLinks": [MockFunction] {
"calls": Array [
[Circular],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"getUserMenuLinks$": [MockFunction],
},
"uiApi": Object {
"components": Object {
"getChangePassword": [MockFunction],
"getPersonalInfo": [MockFunction],
},
},
"userProfiles": Object {
"bulkGet": [MockFunction],
"getCurrent": [MockFunction],
"suggest": [MockFunction],
"update": [MockFunction],
"userProfile$": Observable {
"_subscribe": [Function],
},
},
}
}
uiSettingsClient={
Object {
"get": [MockFunction],
"get$": [MockFunction],
"getAll": [MockFunction],
"getUpdate$": [MockFunction],
"getUpdateErrors$": [MockFunction],
"isCustom": [MockFunction],
"isDeclared": [MockFunction],
"isDefault": [MockFunction],
"isOverridden": [MockFunction],
"remove": [MockFunction],
"set": [MockFunction],
}
}
/>,
"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));

View file

@ -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);
})
);

View file

@ -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,
};
};

View file

@ -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(
<ThemDarkModeToggle security={security} uiSettingsClient={uiSettings} />
);
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(<ThemDarkModeToggle security={security} uiSettingsClient={uiSettings} />);
fireEvent.click(toggleSwitch);
expect(mockUpdate).toHaveBeenLastCalledWith({ userSettings: { darkMode: 'light' } });
});
});

View file

@ -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 (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" gutterSize="xs">
<EuiFlexItem>
<EuiContextMenuItem
icon={colorScheme === 'dark' ? 'moon' : 'sun'}
size="s"
onClick={() => {
const on = colorScheme === 'light' ? true : false;
toggle(on);
}}
data-test-subj="darkModeToggle"
>
{i18n.translate('xpack.cloudLinks.userMenuLinks.darkModeToggle', {
defaultMessage: 'Dark mode',
})}
</EuiContextMenuItem>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ paddingRight: euiTheme.size.m }}>
<EuiSwitch
label={
isDarkModeOn
? i18n.translate('xpack.cloudLinks.userMenuLinks.darkModeOnLabel', {
defaultMessage: 'on',
})
: i18n.translate('xpack.cloudLinks.userMenuLinks.darkModeOffLabel', {
defaultMessage: 'off',
})
}
showLabel={false}
checked={isDarkModeOn}
onChange={(e) => {
toggle(e.target.checked);
}}
aria-describedby={toggleTextSwitchId}
data-test-subj="darkModeToggleSwitch"
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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: <ThemDarkModeToggle security={security} uiSettingsClient={uiSettingsClient} />,
order: 400,
label: '',
iconType: '',
href: '',
});
return userMenuLinks;
};

View file

@ -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,
});
}
}
}

View file

@ -19,6 +19,7 @@
"@kbn/guided-onboarding-plugin",
"@kbn/core-chrome-browser",
"@kbn/core-doc-links-browser",
"@kbn/core-ui-settings-browser",
],
"exclude": [
"target/**/*",

View file

@ -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;
}
/**

View file

@ -11,4 +11,5 @@ export type {
UserProfileBulkGetParams,
UserProfileGetCurrentParams,
UserProfileSuggestParams,
UpdateUserProfileHook,
} from './user_profile';

View file

@ -13,3 +13,5 @@ export type {
UserProfileBulkGetParams,
UserProfileSuggestParams,
} from './user_profile_api_client';
export type { UpdateUserProfileHook } from './use_update_user_profile';

View file

@ -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);
});
});

View file

@ -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<UserProfileData | null>();
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(
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<p>{pageReloadText}</p>
<EuiButton
size="s"
onClick={() => window.location.reload()}
data-test-subj="windowReloadButton"
>
{i18n.translate(
'xpack.security.accountManagement.userProfile.requiresPageReloadToastButtonLabel',
{
defaultMessage: 'Reload page',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
};
}
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(
<D extends UserProfileData>(udpatedData: D) => {
userProfileSnapshot.current = userProfileData;
setIsLoading(true);
return apiClient.update(udpatedData).then(() => onUserProfileUpdate(udpatedData));
},
[onUserProfileUpdate, userProfileData]
);
return {
update,
showSuccessNotification,
userProfileData,
isLoading,
};
};
return useUpdateUserProfile;
};

View file

@ -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<CoreStart>();
const { userProfiles, users } = useSecurityApiClients();
const { update, showSuccessNotification } = getUseUpdateUserProfile({
apiClient: userProfiles,
notifications: services.notifications,
})({ notificationSuccess: { enabled: false } });
const [initialValues, resetInitialValues] = useState<UserProfileFormValues>({
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(
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<p>
{i18n.translate(
'xpack.security.accountManagement.userProfile.requiresPageReloadToastDescription',
{
defaultMessage:
'One or more settings require you to reload the page to take effect.',
}
)}
</p>
<EuiButton
size="s"
onClick={() => window.location.reload()}
data-test-subj="windowReloadButton"
>
{i18n.translate(
'xpack.security.accountManagement.userProfile.requiresPageReloadToastButtonLabel',
{
defaultMessage: 'Reload page',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
};
}
services.notifications.toasts.addSuccess(successToastInput, successToastOptions);
showSuccessNotification({ isRefreshRequired });
},
initialValues,
enableReinitialize: true,

View file

@ -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);

View file

@ -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<UserProfileData> =
this.internalDataUpdates$.asObservable();
private readonly _userProfile$ = new BehaviorSubject<UserProfileData | null>(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<D extends UserProfileData>(params?: UserProfileGetCurrentParams) {
return this.http.get<GetUserProfileResponse<D>>('/internal/security/user_profile', {
query: { dataPath: params?.dataPath },
});
return this.http
.get<GetUserProfileResponse<D>>('/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<D extends UserProfileData>(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);
});
}
}

View file

@ -24,6 +24,7 @@ export type {
UserProfileBulkGetParams,
UserProfileGetCurrentParams,
UserProfileSuggestParams,
UpdateUserProfileHook,
} from './account_management';
export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication';

View file

@ -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(),
},
};
}

View file

@ -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 = () => <div>Dummy Component</div>;
const wrapper = shallow(
<SecurityNavControl
editProfileUrl="edit-profile-link"
@ -175,6 +177,13 @@ describe('SecurityNavControl', () => {
{ 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": <ContextMenuContent
items={
Array [
Object {
"data-test-subj": "profileLink",
"href": "edit-profile-link",
"icon": <EuiIcon
size="m"
type="user"
/>,
"name": <FormattedMessage
defaultMessage="Edit profile"
id="xpack.security.navControlComponent.editProfileLinkText"
values={Object {}}
/>,
"onClick": [Function],
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link1",
"href": "path-to-link-1",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link1",
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link2",
"href": "path-to-link-2",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link2",
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link3",
"href": "path-to-link-3",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link3",
},
Object {
"content": [Function],
"data-test-subj": "userMenuLink__dummyComponent",
"href": "",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "dummyComponent",
},
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log out"
id="xpack.security.navControlComponent.logoutLinkText"
values={Object {}}
/>,
},
]
}
/>,
"id": 0,
"items": Array [
Object {
"data-test-subj": "profileLink",
"href": "edit-profile-link",
"icon": <EuiIcon
size="m"
type="user"
/>,
"name": <FormattedMessage
defaultMessage="Edit profile"
id="xpack.security.navControlComponent.editProfileLinkText"
values={Object {}}
/>,
"onClick": [Function],
},
Object {
"data-test-subj": "userMenuLink__link1",
"href": "path-to-link-1",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link1",
},
Object {
"data-test-subj": "userMenuLink__link2",
"href": "path-to-link-2",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link2",
},
Object {
"data-test-subj": "userMenuLink__link3",
"href": "path-to-link-3",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link3",
},
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log out"
id="xpack.security.navControlComponent.logoutLinkText"
values={Object {}}
/>,
},
],
"title": "full name",
},
]
@ -270,49 +296,56 @@ describe('SecurityNavControl', () => {
expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(`
Array [
Object {
"content": <ContextMenuContent
items={
Array [
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link1",
"href": "path-to-link-1",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link1",
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link2",
"href": "path-to-link-2",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link2",
},
Object {
"content": undefined,
"data-test-subj": "userMenuLink__link3",
"href": "path-to-link-3",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link3",
},
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log out"
id="xpack.security.navControlComponent.logoutLinkText"
values={Object {}}
/>,
},
]
}
/>,
"id": 0,
"items": Array [
Object {
"data-test-subj": "userMenuLink__link1",
"href": "path-to-link-1",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link1",
},
Object {
"data-test-subj": "userMenuLink__link2",
"href": "path-to-link-2",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link2",
},
Object {
"data-test-subj": "userMenuLink__link3",
"href": "path-to-link-3",
"icon": <EuiIcon
size="m"
type="empty"
/>,
"name": "link3",
},
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log out"
id="xpack.security.navControlComponent.logoutLinkText"
values={Object {}}
/>,
},
],
"title": "full name",
},
]
@ -340,22 +373,26 @@ describe('SecurityNavControl', () => {
expect(wrapper.find(EuiContextMenu).prop('panels')).toMatchInlineSnapshot(`
Array [
Object {
"content": <ContextMenuContent
items={
Array [
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log in"
id="xpack.security.navControlComponent.loginLinkText"
values={Object {}}
/>,
},
]
}
/>,
"id": 0,
"items": Array [
Object {
"data-test-subj": "logoutLink",
"href": "",
"icon": <EuiIcon
size="m"
type="exit"
/>,
"name": <FormattedMessage
defaultMessage="Log in"
id="xpack.security.navControlComponent.loginLinkText"
values={Object {}}
/>,
},
],
"title": "full name",
},
]

View file

@ -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 <EuiContextMenuItem /> */
content?: ReactNode;
}
type ContextMenuItem = EuiContextMenuPanelItemDescriptor & { content?: ReactNode };
interface ContextMenuProps {
items: ContextMenuItem[];
}
const ContextMenuContent = ({ items }: ContextMenuProps) => {
return (
<>
<EuiContextMenuPanel>
{items.map((item, i) => {
if (item.content) {
return <Fragment key={i}>{item.content}</Fragment>;
}
return (
<EuiContextMenuItem
key={i}
icon={item.icon}
size="s"
href={item.href}
data-test-subj={item['data-test-subj']}
>
{item.name}
</EuiContextMenuItem>
);
})}
</EuiContextMenuPanel>
</>
);
};
interface SecurityNavControlProps {
editProfileUrl: string;
logoutUrl: string;
@ -48,7 +83,7 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
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<SecurityNavControlProps> = ({
</EuiHeaderSectionItemButton>
);
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: <EuiIcon type={iconType} size="m" />,
href,
'data-test-subj': `userMenuLink__${label}`,
content,
}));
items.push(...userMenuLinkMenuItems);
}
@ -153,7 +189,7 @@ export const SecurityNavControl: FunctionComponent<SecurityNavControlProps> = ({
{
id: 0,
title: displayName,
items,
content: <ContextMenuContent items={items} />,
},
]}
/>

View file

@ -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,
},
},
},
}
`);

View file

@ -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<UserProfileAPIClient, 'getCurrent' | 'bulkGet' | 'suggest'>;
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.

View file

@ -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({});
});
});

View file

@ -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<any, any | any[]> | null | undefined,
accDefault: Record<string, any> = {},
parentKey?: string
): Record<string, any> => {
if (item) {
const isArrayWithSingleValue = Array.isArray(item) && item.length === 1;
return Object.keys(item)
.sort()
.reduce<Record<string, any>>((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 {};
};

View file

@ -11,3 +11,4 @@ export {
validateKibanaPrivileges,
transformPrivilegesToElasticsearchPrivileges,
} from './role_utils';
export { flattenObject } from './flatten_object';

View file

@ -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,

View file

@ -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));

View file

@ -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);
});
});
});
}