Adds cloud links to user menu (#82803)

Co-authored-by: Ryan Keairns <contactryank@gmail.com>
This commit is contained in:
Catherine Liu 2020-11-10 09:51:27 -06:00 committed by GitHub
parent 00ca555cd9
commit 4dba10c76a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 424 additions and 61 deletions

View file

@ -3,7 +3,7 @@
"version": "8.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "cloud"],
"optionalPlugins": ["usageCollection", "home"],
"optionalPlugins": ["usageCollection", "home", "security"],
"server": true,
"ui": true
}

View file

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

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

View file

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

View 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;
};

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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