diff --git a/x-pack/plugins/cloud/kibana.json b/x-pack/plugins/cloud/kibana.json index 27b35bcbdd88..9bca2f30bd23 100644 --- a/x-pack/plugins/cloud/kibana.json +++ b/x-pack/plugins/cloud/kibana.json @@ -3,7 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "cloud"], - "optionalPlugins": ["usageCollection", "home"], + "optionalPlugins": ["usageCollection", "home", "security"], "server": true, "ui": true } diff --git a/x-pack/plugins/cloud/public/index.ts b/x-pack/plugins/cloud/public/index.ts index 39ef5f452c18..680b2f1ad2bd 100644 --- a/x-pack/plugins/cloud/public/index.ts +++ b/x-pack/plugins/cloud/public/index.ts @@ -7,7 +7,7 @@ import { PluginInitializerContext } from '../../../../src/core/public'; import { CloudPlugin } from './plugin'; -export { CloudSetup } from './plugin'; +export { CloudSetup, CloudConfigType } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new CloudPlugin(initializerContext); } diff --git a/x-pack/plugins/cloud/public/mocks.ts b/x-pack/plugins/cloud/public/mocks.ts new file mode 100644 index 000000000000..bafebbca4ecd --- /dev/null +++ b/x-pack/plugins/cloud/public/mocks.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +function createSetupMock() { + return { + cloudId: 'mock-cloud-id', + isCloudEnabled: true, + resetPasswordUrl: 'reset-password-url', + accountUrl: 'account-url', + }; +} + +export const cloudMock = { + createSetup: createSetupMock, +}; diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.ts index 45005f3f5e42..bc410b89c30e 100644 --- a/x-pack/plugins/cloud/public/plugin.ts +++ b/x-pack/plugins/cloud/public/plugin.ts @@ -6,40 +6,51 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { i18n } from '@kbn/i18n'; +import { SecurityPluginStart } from '../../security/public'; import { getIsCloudEnabled } from '../common/is_cloud_enabled'; import { ELASTIC_SUPPORT_LINK } from '../common/constants'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { createUserMenuLinks } from './user_menu_links'; -interface CloudConfigType { +export interface CloudConfigType { id?: string; resetPasswordUrl?: string; deploymentUrl?: string; + accountUrl?: string; } interface CloudSetupDependencies { home?: HomePublicPluginSetup; } +interface CloudStartDependencies { + security?: SecurityPluginStart; +} + export interface CloudSetup { cloudId?: string; cloudDeploymentUrl?: string; isCloudEnabled: boolean; + resetPasswordUrl?: string; + accountUrl?: string; } export class CloudPlugin implements Plugin { private config!: CloudConfigType; + private isCloudEnabled: boolean; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.isCloudEnabled = false; } public async setup(core: CoreSetup, { home }: CloudSetupDependencies) { const { id, resetPasswordUrl, deploymentUrl } = this.config; - const isCloudEnabled = getIsCloudEnabled(id); + this.isCloudEnabled = getIsCloudEnabled(id); if (home) { - home.environment.update({ cloud: isCloudEnabled }); - if (isCloudEnabled) { + home.environment.update({ cloud: this.isCloudEnabled }); + if (this.isCloudEnabled) { home.tutorials.setVariable('cloud', { id, resetPasswordUrl }); } } @@ -47,11 +58,11 @@ export class CloudPlugin implements Plugin { return { cloudId: id, cloudDeploymentUrl: deploymentUrl, - isCloudEnabled, + isCloudEnabled: this.isCloudEnabled, }; } - public start(coreStart: CoreStart) { + public start(coreStart: CoreStart, { security }: CloudStartDependencies) { const { deploymentUrl } = this.config; coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK); if (deploymentUrl) { @@ -63,5 +74,10 @@ export class CloudPlugin implements Plugin { href: deploymentUrl, }); } + + if (security && this.isCloudEnabled) { + const userMenuLinks = createUserMenuLinks(this.config); + security.navControlService.addUserMenuLinks(userMenuLinks); + } } } diff --git a/x-pack/plugins/cloud/public/user_menu_links.ts b/x-pack/plugins/cloud/public/user_menu_links.ts new file mode 100644 index 000000000000..15e2f14e885b --- /dev/null +++ b/x-pack/plugins/cloud/public/user_menu_links.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { UserMenuLink } from '../../security/public'; +import { CloudConfigType } from '.'; + +export const createUserMenuLinks = (config: CloudConfigType): UserMenuLink[] => { + const { resetPasswordUrl, accountUrl } = config; + const userMenuLinks = [] as UserMenuLink[]; + + if (resetPasswordUrl) { + userMenuLinks.push({ + label: i18n.translate('xpack.cloud.userMenuLinks.profileLinkText', { + defaultMessage: 'Cloud profile', + }), + iconType: 'logoCloud', + href: resetPasswordUrl, + order: 100, + }); + } + + if (accountUrl) { + userMenuLinks.push({ + label: i18n.translate('xpack.cloud.userMenuLinks.accountLinkText', { + defaultMessage: 'Account & Billing', + }), + iconType: 'gear', + href: accountUrl, + order: 200, + }); + } + + return userMenuLinks; +}; diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts index ff8a2c5acdf9..eaa4ab7a482d 100644 --- a/x-pack/plugins/cloud/server/config.ts +++ b/x-pack/plugins/cloud/server/config.ts @@ -23,6 +23,7 @@ const configSchema = schema.object({ apm: schema.maybe(apmConfigSchema), resetPasswordUrl: schema.maybe(schema.string()), deploymentUrl: schema.maybe(schema.string()), + accountUrl: schema.maybe(schema.string()), }); export type CloudConfigType = TypeOf; @@ -32,6 +33,7 @@ export const config: PluginConfigDescriptor = { id: true, resetPasswordUrl: true, deploymentUrl: true, + accountUrl: true, }, schema: configSchema, }; diff --git a/x-pack/plugins/security/public/index.ts b/x-pack/plugins/security/public/index.ts index 8016c9422406..d0382c22ed3c 100644 --- a/x-pack/plugins/security/public/index.ts +++ b/x-pack/plugins/security/public/index.ts @@ -16,6 +16,7 @@ import { export { SecurityPluginSetup, SecurityPluginStart }; export { AuthenticatedUser } from '../common/model'; export { SecurityLicense, SecurityLicenseFeatures } from '../common/licensing'; +export { UserMenuLink } from '../public/nav_control'; export const plugin: PluginInitializer< SecurityPluginSetup, diff --git a/x-pack/plugins/security/public/mocks.ts b/x-pack/plugins/security/public/mocks.ts index 33c1d1446afb..26a759ca5226 100644 --- a/x-pack/plugins/security/public/mocks.ts +++ b/x-pack/plugins/security/public/mocks.ts @@ -7,6 +7,7 @@ import { authenticationMock } from './authentication/index.mock'; import { createSessionTimeoutMock } from './session/session_timeout.mock'; import { licenseMock } from '../common/licensing/index.mock'; +import { navControlServiceMock } from './nav_control/index.mock'; function createSetupMock() { return { @@ -15,7 +16,13 @@ function createSetupMock() { license: licenseMock.create(), }; } +function createStartMock() { + return { + navControlService: navControlServiceMock.createStart(), + }; +} export const securityMock = { createSetup: createSetupMock, + createStart: createStartMock, }; diff --git a/x-pack/plugins/security/public/nav_control/index.mock.ts b/x-pack/plugins/security/public/nav_control/index.mock.ts new file mode 100644 index 000000000000..1cd10810d7c8 --- /dev/null +++ b/x-pack/plugins/security/public/nav_control/index.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SecurityNavControlServiceStart } from '.'; + +export const navControlServiceMock = { + createStart: (): jest.Mocked => ({ + getUserMenuLinks$: jest.fn(), + addUserMenuLinks: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/public/nav_control/index.ts b/x-pack/plugins/security/public/nav_control/index.ts index 2b0af1a45d05..737ae5005469 100644 --- a/x-pack/plugins/security/public/nav_control/index.ts +++ b/x-pack/plugins/security/public/nav_control/index.ts @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SecurityNavControlService } from './nav_control_service'; +export { SecurityNavControlService, SecurityNavControlServiceStart } from './nav_control_service'; +export { UserMenuLink } from './nav_control_component'; diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.scss b/x-pack/plugins/security/public/nav_control/nav_control_component.scss new file mode 100644 index 000000000000..a3e04b08cfac --- /dev/null +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.scss @@ -0,0 +1,11 @@ +.chrNavControl__userMenu { + .euiContextMenuPanelTitle { + // Uppercased by default, override to match actual username + text-transform: none; + } + + .euiContextMenuItem { + // Temp fix for EUI issue https://github.com/elastic/eui/issues/3092 + line-height: normal; + } +} \ No newline at end of file diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx index c1c6a9f69b6e..1da91e80d062 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.test.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { BehaviorSubject } from 'rxjs'; import { shallowWithIntl, nextTick, mountWithIntl } from 'test_utils/enzyme_helpers'; import { SecurityNavControl } from './nav_control_component'; import { AuthenticatedUser } from '../../common/model'; @@ -17,6 +18,7 @@ describe('SecurityNavControl', () => { user: new Promise(() => {}) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -42,6 +44,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = shallowWithIntl(); @@ -70,6 +73,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -91,6 +95,7 @@ describe('SecurityNavControl', () => { user: Promise.resolve({ full_name: 'foo' }) as Promise, editProfileUrl: '', logoutUrl: '', + userMenuLinks$: new BehaviorSubject([]), }; const wrapper = mountWithIntl(); @@ -107,4 +112,37 @@ describe('SecurityNavControl', () => { expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); }); + + it('renders a popover with additional user menu links registered by other plugins', async () => { + const props = { + user: Promise.resolve({ full_name: 'foo' }) as Promise, + editProfileUrl: '', + logoutUrl: '', + userMenuLinks$: new BehaviorSubject([ + { label: 'link1', href: 'path-to-link-1', iconType: 'empty', order: 1 }, + { label: 'link2', href: 'path-to-link-2', iconType: 'empty', order: 2 }, + { label: 'link3', href: 'path-to-link-3', iconType: 'empty', order: 3 }, + ]), + }; + + const wrapper = mountWithIntl(); + await nextTick(); + wrapper.update(); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(0); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(0); + expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(0); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(0); + + wrapper.find(EuiHeaderSectionItemButton).simulate('click'); + + expect(findTestSubject(wrapper, 'userMenu')).toHaveLength(1); + expect(findTestSubject(wrapper, 'profileLink')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link1')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link2')).toHaveLength(1); + expect(findTestSubject(wrapper, 'userMenuLink__link3')).toHaveLength(1); + expect(findTestSubject(wrapper, 'logoutLink')).toHaveLength(1); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx index 3ddabb0dc55f..c22308fa8a43 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_component.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_component.tsx @@ -7,38 +7,52 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { Component } from 'react'; - +import { Observable, Subscription } from 'rxjs'; import { EuiAvatar, - EuiFlexGroup, - EuiFlexItem, EuiHeaderSectionItemButton, - EuiLink, - EuiText, - EuiSpacer, EuiPopover, EuiLoadingSpinner, + EuiIcon, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + IconType, + EuiText, } from '@elastic/eui'; import { AuthenticatedUser } from '../../common/model'; +import './nav_control_component.scss'; + +export interface UserMenuLink { + label: string; + iconType: IconType; + href: string; + order?: number; +} + interface Props { user: Promise; editProfileUrl: string; logoutUrl: string; + userMenuLinks$: Observable; } interface State { isOpen: boolean; authenticatedUser: AuthenticatedUser | null; + userMenuLinks: UserMenuLink[]; } export class SecurityNavControl extends Component { + private subscription?: Subscription; + constructor(props: Props) { super(props); this.state = { isOpen: false, authenticatedUser: null, + userMenuLinks: [], }; props.user.then((authenticatedUser) => { @@ -48,6 +62,18 @@ export class SecurityNavControl extends Component { }); } + componentDidMount() { + this.subscription = this.props.userMenuLinks$.subscribe(async (userMenuLinks) => { + this.setState({ userMenuLinks }); + }); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + onMenuButtonClick = () => { if (!this.state.authenticatedUser) { return; @@ -66,13 +92,13 @@ export class SecurityNavControl extends Component { render() { const { editProfileUrl, logoutUrl } = this.props; - const { authenticatedUser } = this.state; + const { authenticatedUser, userMenuLinks } = this.state; - const name = + const username = (authenticatedUser && (authenticatedUser.full_name || authenticatedUser.username)) || ''; const buttonContents = authenticatedUser ? ( - + ) : ( ); @@ -92,6 +118,60 @@ export class SecurityNavControl extends Component { ); + const profileMenuItem = { + name: ( + + ), + icon: , + href: editProfileUrl, + 'data-test-subj': 'profileLink', + }; + + const logoutMenuItem = { + name: ( + + ), + icon: , + href: logoutUrl, + 'data-test-subj': 'logoutLink', + }; + + const items: EuiContextMenuPanelItemDescriptor[] = []; + + items.push(profileMenuItem); + + if (userMenuLinks.length) { + const userMenuLinkMenuItems = userMenuLinks + .sort(({ order: orderA = Infinity }, { order: orderB = Infinity }) => orderA - orderB) + .map(({ label, iconType, href }: UserMenuLink) => ({ + name: {label}, + icon: , + href, + 'data-test-subj': `userMenuLink__${label}`, + })); + + items.push(...userMenuLinkMenuItems, { + isSeparator: true, + key: 'securityNavControlComponent__userMenuLinksSeparator', + }); + } + + items.push(logoutMenuItem); + + const panels = [ + { + id: 0, + title: username, + items, + }, + ]; + return ( { repositionOnScroll closePopover={this.closeMenu} panelPaddingSize="none" + buffer={0} > -
- - - - - - - -

{name}

-
- - - - - - - - - - - - - - - - - - - - -
-
+
+
); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts index acf62f3376b8..5b9788d67500 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.test.ts @@ -173,4 +173,134 @@ describe('SecurityNavControlService', () => { navControlService.start({ core: coreStart }); expect(coreStart.chrome.navControls.registerRight).toHaveBeenCalledTimes(2); }); + + describe(`#start`, () => { + it('should return functions to register and retrieve user menu links', () => { + const license$ = new BehaviorSubject(validLicense); + + const navControlService = new SecurityNavControlService(); + navControlService.setup({ + securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', + }); + + const coreStart = coreMock.createStart(); + const navControlServiceStart = navControlService.start({ core: coreStart }); + expect(navControlServiceStart).toHaveProperty('getUserMenuLinks$'); + expect(navControlServiceStart).toHaveProperty('addUserMenuLinks'); + }); + + it('should register custom user menu links to be displayed in the nav controls', (done) => { + const license$ = new BehaviorSubject(validLicense); + + const navControlService = new SecurityNavControlService(); + navControlService.setup({ + securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', + }); + + const coreStart = coreMock.createStart(); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const userMenuLinks$ = getUserMenuLinks$(); + + addUserMenuLinks([ + { + label: 'link1', + href: 'path-to-link1', + iconType: 'empty', + }, + ]); + + userMenuLinks$.subscribe((links) => { + expect(links).toMatchInlineSnapshot(` + Array [ + Object { + "href": "path-to-link1", + "iconType": "empty", + "label": "link1", + }, + ] + `); + done(); + }); + }); + + it('should retrieve user menu links sorted by order', (done) => { + const license$ = new BehaviorSubject(validLicense); + + const navControlService = new SecurityNavControlService(); + navControlService.setup({ + securityLicense: new SecurityLicenseService().setup({ license$ }).license, + authc: securityMock.createSetup().authc, + logoutUrl: '/some/logout/url', + }); + + const coreStart = coreMock.createStart(); + const { getUserMenuLinks$, addUserMenuLinks } = navControlService.start({ core: coreStart }); + const userMenuLinks$ = getUserMenuLinks$(); + + addUserMenuLinks([ + { + label: 'link3', + href: 'path-to-link3', + iconType: 'empty', + order: 3, + }, + { + label: 'link1', + href: 'path-to-link1', + iconType: 'empty', + order: 1, + }, + { + label: 'link2', + href: 'path-to-link2', + iconType: 'empty', + order: 2, + }, + ]); + addUserMenuLinks([ + { + label: 'link4', + href: 'path-to-link4', + iconType: 'empty', + order: 4, + }, + ]); + + userMenuLinks$.subscribe((links) => { + expect(links).toMatchInlineSnapshot(` + Array [ + Object { + "href": "path-to-link1", + "iconType": "empty", + "label": "link1", + "order": 1, + }, + Object { + "href": "path-to-link2", + "iconType": "empty", + "label": "link2", + "order": 2, + }, + Object { + "href": "path-to-link3", + "iconType": "empty", + "label": "link3", + "order": 3, + }, + Object { + "href": "path-to-link4", + "iconType": "empty", + "label": "link4", + "order": 4, + }, + ] + `); + done(); + }); + }); + }); }); diff --git a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx index aa3ec2e47469..5d2e7d7dfb73 100644 --- a/x-pack/plugins/security/public/nav_control/nav_control_service.tsx +++ b/x-pack/plugins/security/public/nav_control/nav_control_service.tsx @@ -4,12 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Subscription } from 'rxjs'; +import { sortBy } from 'lodash'; +import { Observable, Subscription, BehaviorSubject, ReplaySubject } from 'rxjs'; +import { map, takeUntil } from 'rxjs/operators'; import { CoreStart } from 'src/core/public'; + import ReactDOM from 'react-dom'; import React from 'react'; + import { SecurityLicense } from '../../common/licensing'; -import { SecurityNavControl } from './nav_control_component'; +import { SecurityNavControl, UserMenuLink } from './nav_control_component'; import { AuthenticationServiceSetup } from '../authentication'; interface SetupDeps { @@ -22,6 +26,18 @@ interface StartDeps { core: CoreStart; } +export interface SecurityNavControlServiceStart { + /** + * Returns an Observable of the array of user menu links registered by other plugins + */ + getUserMenuLinks$: () => Observable; + + /** + * Registers the provided user menu links to be displayed in the user menu in the global nav + */ + addUserMenuLinks: (newUserMenuLink: UserMenuLink[]) => void; +} + export class SecurityNavControlService { private securityLicense!: SecurityLicense; private authc!: AuthenticationServiceSetup; @@ -31,13 +47,16 @@ export class SecurityNavControlService { private securityFeaturesSubscription?: Subscription; + private readonly stop$ = new ReplaySubject(1); + private userMenuLinks$ = new BehaviorSubject([]); + public setup({ securityLicense, authc, logoutUrl }: SetupDeps) { this.securityLicense = securityLicense; this.authc = authc; this.logoutUrl = logoutUrl; } - public start({ core }: StartDeps) { + public start({ core }: StartDeps): SecurityNavControlServiceStart { this.securityFeaturesSubscription = this.securityLicense.features$.subscribe( ({ showLinks }) => { const isAnonymousPath = core.http.anonymousPaths.isAnonymous(window.location.pathname); @@ -49,6 +68,16 @@ export class SecurityNavControlService { } } ); + + return { + getUserMenuLinks$: () => + this.userMenuLinks$.pipe(map(this.sortUserMenuLinks), takeUntil(this.stop$)), + addUserMenuLinks: (userMenuLinks: UserMenuLink[]) => { + const currentLinks = this.userMenuLinks$.value; + const newLinks = [...currentLinks, ...userMenuLinks]; + this.userMenuLinks$.next(newLinks); + }, + }; } public stop() { @@ -57,6 +86,7 @@ export class SecurityNavControlService { this.securityFeaturesSubscription = undefined; } this.navControlRegistered = false; + this.stop$.next(); } private registerSecurityNavControl( @@ -72,6 +102,7 @@ export class SecurityNavControlService { user: currentUserPromise, editProfileUrl: core.http.basePath.prepend('/security/account'), logoutUrl: this.logoutUrl, + userMenuLinks$: this.userMenuLinks$, }; ReactDOM.render( @@ -86,4 +117,8 @@ export class SecurityNavControlService { this.navControlRegistered = true; } + + private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) { + return sortBy(userMenuLinks, 'order'); + } } diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index d86d4812af5e..6f5a2a031a7b 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -97,7 +97,12 @@ describe('Security Plugin', () => { data: {} as DataPublicPluginStart, features: {} as FeaturesPluginStart, }) - ).toBeUndefined(); + ).toEqual({ + navControlService: { + getUserMenuLinks$: expect.any(Function), + addUserMenuLinks: expect.any(Function), + }, + }); }); it('starts Management Service if `management` plugin is available', () => { diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index 700653c4cecb..f94772c43dd8 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -146,11 +146,13 @@ export class SecurityPlugin public start(core: CoreStart, { management, securityOss }: PluginStartDependencies) { this.sessionTimeout.start(); - this.navControlService.start({ core }); this.securityCheckupService.start({ securityOssStart: securityOss, docLinks: core.docLinks }); + if (management) { this.managementService.start({ capabilities: core.application.capabilities }); } + + return { navControlService: this.navControlService.start({ core }) }; } public stop() {