mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[ColorScheme] Update from avatar menu (#161214)
This commit is contained in:
parent
d166193ac0
commit
3a434bfe85
29 changed files with 1126 additions and 201 deletions
|
@ -116,7 +116,7 @@ pageLoadAssetSize:
|
|||
screenshotMode: 17856
|
||||
screenshotting: 22870
|
||||
searchprofiler: 67080
|
||||
security: 65433
|
||||
security: 81771
|
||||
securitySolution: 66738
|
||||
securitySolutionEss: 16573
|
||||
securitySolutionServerless: 40000
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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' } });
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
"@kbn/guided-onboarding-plugin",
|
||||
"@kbn/core-chrome-browser",
|
||||
"@kbn/core-doc-links-browser",
|
||||
"@kbn/core-ui-settings-browser",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -11,4 +11,5 @@ export type {
|
|||
UserProfileBulkGetParams,
|
||||
UserProfileGetCurrentParams,
|
||||
UserProfileSuggestParams,
|
||||
UpdateUserProfileHook,
|
||||
} from './user_profile';
|
||||
|
|
|
@ -13,3 +13,5 @@ export type {
|
|||
UserProfileBulkGetParams,
|
||||
UserProfileSuggestParams,
|
||||
} from './user_profile_api_client';
|
||||
|
||||
export type { UpdateUserProfileHook } from './use_update_user_profile';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ export type {
|
|||
UserProfileBulkGetParams,
|
||||
UserProfileGetCurrentParams,
|
||||
UserProfileSuggestParams,
|
||||
UpdateUserProfileHook,
|
||||
} from './account_management';
|
||||
|
||||
export type { AuthenticationServiceStart, AuthenticationServiceSetup } from './authentication';
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
|
|
@ -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.
|
||||
|
|
43
x-pack/plugins/security/server/lib/flatten_object.test.ts
Normal file
43
x-pack/plugins/security/server/lib/flatten_object.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
35
x-pack/plugins/security/server/lib/flatten_object.ts
Normal file
35
x-pack/plugins/security/server/lib/flatten_object.ts
Normal 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 {};
|
||||
};
|
|
@ -11,3 +11,4 @@ export {
|
|||
validateKibanaPrivileges,
|
||||
transformPrivilegesToElasticsearchPrivileges,
|
||||
} from './role_utils';
|
||||
export { flattenObject } from './flatten_object';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue