mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Adds cloud links to user menu (#82803)
Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
parent
00ca555cd9
commit
4dba10c76a
17 changed files with 424 additions and 61 deletions
|
@ -3,7 +3,7 @@
|
|||
"version": "8.0.0",
|
||||
"kibanaVersion": "kibana",
|
||||
"configPath": ["xpack", "cloud"],
|
||||
"optionalPlugins": ["usageCollection", "home"],
|
||||
"optionalPlugins": ["usageCollection", "home", "security"],
|
||||
"server": true,
|
||||
"ui": true
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
18
x-pack/plugins/cloud/public/mocks.ts
Normal file
18
x-pack/plugins/cloud/public/mocks.ts
Normal file
|
@ -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,
|
||||
};
|
|
@ -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<CloudSetup> {
|
||||
private config!: CloudConfigType;
|
||||
private isCloudEnabled: boolean;
|
||||
|
||||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||
this.config = this.initializerContext.config.get<CloudConfigType>();
|
||||
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<CloudSetup> {
|
|||
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<CloudSetup> {
|
|||
href: deploymentUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (security && this.isCloudEnabled) {
|
||||
const userMenuLinks = createUserMenuLinks(this.config);
|
||||
security.navControlService.addUserMenuLinks(userMenuLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
38
x-pack/plugins/cloud/public/user_menu_links.ts
Normal file
38
x-pack/plugins/cloud/public/user_menu_links.ts
Normal file
|
@ -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;
|
||||
};
|
|
@ -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<typeof configSchema>;
|
||||
|
@ -32,6 +33,7 @@ export const config: PluginConfigDescriptor<CloudConfigType> = {
|
|||
id: true,
|
||||
resetPasswordUrl: true,
|
||||
deploymentUrl: true,
|
||||
accountUrl: true,
|
||||
},
|
||||
schema: configSchema,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
14
x-pack/plugins/security/public/nav_control/index.mock.ts
Normal file
14
x-pack/plugins/security/public/nav_control/index.mock.ts
Normal file
|
@ -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<SecurityNavControlServiceStart> => ({
|
||||
getUserMenuLinks$: jest.fn(),
|
||||
addUserMenuLinks: jest.fn(),
|
||||
}),
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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<AuthenticatedUser>,
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(<SecurityNavControl {...props} />);
|
||||
|
@ -42,6 +44,7 @@ describe('SecurityNavControl', () => {
|
|||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
};
|
||||
|
||||
const wrapper = shallowWithIntl(<SecurityNavControl {...props} />);
|
||||
|
@ -70,6 +73,7 @@ describe('SecurityNavControl', () => {
|
|||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<SecurityNavControl {...props} />);
|
||||
|
@ -91,6 +95,7 @@ describe('SecurityNavControl', () => {
|
|||
user: Promise.resolve({ full_name: 'foo' }) as Promise<AuthenticatedUser>,
|
||||
editProfileUrl: '',
|
||||
logoutUrl: '',
|
||||
userMenuLinks$: new BehaviorSubject([]),
|
||||
};
|
||||
|
||||
const wrapper = mountWithIntl(<SecurityNavControl {...props} />);
|
||||
|
@ -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<AuthenticatedUser>,
|
||||
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(<SecurityNavControl {...props} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<AuthenticatedUser>;
|
||||
editProfileUrl: string;
|
||||
logoutUrl: string;
|
||||
userMenuLinks$: Observable<UserMenuLink[]>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isOpen: boolean;
|
||||
authenticatedUser: AuthenticatedUser | null;
|
||||
userMenuLinks: UserMenuLink[];
|
||||
}
|
||||
|
||||
export class SecurityNavControl extends Component<Props, State> {
|
||||
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<Props, State> {
|
|||
});
|
||||
}
|
||||
|
||||
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<Props, State> {
|
|||
|
||||
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 ? (
|
||||
<EuiAvatar name={name} size="s" />
|
||||
<EuiAvatar name={username} size="s" />
|
||||
) : (
|
||||
<EuiLoadingSpinner size="m" />
|
||||
);
|
||||
|
@ -92,6 +118,60 @@ export class SecurityNavControl extends Component<Props, State> {
|
|||
</EuiHeaderSectionItemButton>
|
||||
);
|
||||
|
||||
const profileMenuItem = {
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.editProfileLinkText"
|
||||
defaultMessage="Profile"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="user" size="m" />,
|
||||
href: editProfileUrl,
|
||||
'data-test-subj': 'profileLink',
|
||||
};
|
||||
|
||||
const logoutMenuItem = {
|
||||
name: (
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.logoutLinkText"
|
||||
defaultMessage="Log out"
|
||||
/>
|
||||
),
|
||||
icon: <EuiIcon type="exit" size="m" />,
|
||||
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: <EuiText>{label}</EuiText>,
|
||||
icon: <EuiIcon type={iconType} size="m" />,
|
||||
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 (
|
||||
<EuiPopover
|
||||
id="headerUserMenu"
|
||||
|
@ -102,45 +182,10 @@ export class SecurityNavControl extends Component<Props, State> {
|
|||
repositionOnScroll
|
||||
closePopover={this.closeMenu}
|
||||
panelPaddingSize="none"
|
||||
buffer={0}
|
||||
>
|
||||
<div style={{ width: 320 }} data-test-subj="userMenu">
|
||||
<EuiFlexGroup gutterSize="m" className="euiHeaderProfile" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiAvatar name={name} size="xl" />
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiText>
|
||||
<p className="eui-textBreakWord">{name}</p>
|
||||
</EuiText>
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLink href={editProfileUrl} data-test-subj="profileLink">
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.editProfileLinkText"
|
||||
defaultMessage="Edit profile"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLink href={logoutUrl} data-test-subj="logoutLink">
|
||||
<FormattedMessage
|
||||
id="xpack.security.navControlComponent.logoutLinkText"
|
||||
defaultMessage="Log out"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<div data-test-subj="userMenu">
|
||||
<EuiContextMenu className="chrNavControl__userMenu" initialPanelId={0} panels={panels} />
|
||||
</div>
|
||||
</EuiPopover>
|
||||
);
|
||||
|
|
|
@ -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<ILicense>(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<ILicense>(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<ILicense>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<UserMenuLink[]>;
|
||||
|
||||
/**
|
||||
* 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<UserMenuLink[]>([]);
|
||||
|
||||
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(
|
||||
<I18nContext>
|
||||
|
@ -86,4 +117,8 @@ export class SecurityNavControlService {
|
|||
|
||||
this.navControlRegistered = true;
|
||||
}
|
||||
|
||||
private sortUserMenuLinks(userMenuLinks: UserMenuLink[]) {
|
||||
return sortBy(userMenuLinks, 'order');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue