diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index 550ec608a76cb..6598e0dc29426 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -10,7 +10,8 @@ import { i18n } from '@kbn/i18n'; import { get } from 'lodash'; import { LicenseType } from '@kbn/licensing-plugin/common/types'; import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import { AppDeepLink, AppNavLinkStatus, AppUpdater, Capabilities } from '@kbn/core/public'; +import { Subject } from 'rxjs'; import { SecurityPageName } from '../types'; import { OVERVIEW, @@ -63,6 +64,8 @@ import { RULES_CREATE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; +import { subscribeAppLinks } from '../../common/links'; +import { AppLinkItems } from '../../common/links/types'; const FEATURE = { general: `${SERVER_APP_ID}.show`, @@ -553,3 +556,37 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { licenseType === 'trial' ); } + +/** + * New deep links code starts here. + * All the code above will be removed once the appLinks migration is over. + * The code below manages the new implementation using the unified appLinks. + */ + +const formatDeepLinks = (appLinks: AppLinkItems): AppDeepLink[] => + appLinks.map((appLink) => ({ + id: appLink.id, + path: appLink.path, + title: appLink.title, + navLinkStatus: appLink.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden, + searchable: !appLink.globalSearchDisabled, + ...(appLink.globalSearchKeywords != null ? { keywords: appLink.globalSearchKeywords } : {}), + ...(appLink.globalNavOrder != null ? { order: appLink.globalNavOrder } : {}), + ...(appLink.links && appLink.links?.length + ? { + deepLinks: formatDeepLinks(appLink.links), + } + : {}), + })); + +/** + * Registers any change in appLinks to be updated in app deepLinks + */ +export const registerDeepLinksUpdater = (appUpdater$: Subject) => { + subscribeAppLinks((appLinks) => { + appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // needed to prevent main security link to switch to visible after update + deepLinks: formatDeepLinks(appLinks), + })); + }); +}; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index 3b436d2bdefc1..8d7d9daad550d 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -26,6 +26,7 @@ import { gutterTimeline } from '../../../common/lib/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; +import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers'; const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -44,8 +45,7 @@ const NO_DATA_PAGE_TEMPLATE_PROPS = { */ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ $isShowingTimelineOverlay?: boolean; - $isTimelineBottomBarVisible?: boolean; - $isPolicySettingsVisible?: boolean; + $addBottomPadding?: boolean; }>` .${BOTTOM_BAR_CLASSNAME} { animation: 'none !important'; // disable the default bottom bar slide animation @@ -63,19 +63,8 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ } // If the bottom bar is visible add padding to the navigation - ${({ $isTimelineBottomBarVisible }) => - $isTimelineBottomBarVisible && - ` - @media (min-width: 768px) { - .kbnPageTemplateSolutionNav { - padding-bottom: ${gutterTimeline}; - } - } - `} - - // If the policy settings bottom bar is visible add padding to the navigation - ${({ $isPolicySettingsVisible }) => - $isPolicySettingsVisible && + ${({ $addBottomPadding }) => + $addBottomPadding && ` @media (min-width: 768px) { .kbnPageTemplateSolutionNav { @@ -98,6 +87,9 @@ export const SecuritySolutionTemplateWrapper: React.FC getTimelineShowStatus(state, TimelineId.active) ); + const isGroupedNavEnabled = useIsGroupedNavigationEnabled(); + const addBottomPadding = + isTimelineBottomBarVisible || isPolicySettingsVisible || isGroupedNavEnabled; const userHasSecuritySolutionVisible = useKibana().services.application.capabilities.siem.show; const showEmptyState = useShowPagesWithEmptyView(); @@ -117,9 +109,8 @@ export const SecuritySolutionTemplateWrapper: React.FC diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 9857e7160a209..354ba438ff52a 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -23,7 +23,7 @@ export const HOSTS = i18n.translate('xpack.securitySolution.navigation.hosts', { }); export const GETTING_STARTED = i18n.translate('xpack.securitySolution.navigation.gettingStarted', { - defaultMessage: 'Getting started', + defaultMessage: 'Get started', }); export const THREAT_HUNTING = i18n.translate('xpack.securitySolution.navigation.threatHunting', { diff --git a/x-pack/plugins/security_solution/public/cases/links.ts b/x-pack/plugins/security_solution/public/cases/links.ts index 9ed7a1f3980a6..bafaee6baa583 100644 --- a/x-pack/plugins/security_solution/public/cases/links.ts +++ b/x-pack/plugins/security_solution/public/cases/links.ts @@ -6,8 +6,8 @@ */ import { getCasesDeepLinks } from '@kbn/cases-plugin/public'; -import { CASES_PATH, SecurityPageName } from '../../common/constants'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { CASES_FEATURE_ID, CASES_PATH, SecurityPageName } from '../../common/constants'; +import { LinkItem } from '../common/links/types'; export const getCasesLinkItems = (): LinkItem => { const casesLinks = getCasesDeepLinks({ @@ -16,15 +16,17 @@ export const getCasesLinkItems = (): LinkItem => { [SecurityPageName.case]: { globalNavEnabled: true, globalNavOrder: 9006, - features: [FEATURE.casesRead], + capabilities: [`${CASES_FEATURE_ID}.read_cases`], }, [SecurityPageName.caseConfigure]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], licenseType: 'gold', + sideNavDisabled: true, hideTimeline: true, }, [SecurityPageName.caseCreate]: { - features: [FEATURE.casesCrud], + capabilities: [`${CASES_FEATURE_ID}.crud_cases`], + sideNavDisabled: true, hideTimeline: true, }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts index ff7aa7581fc4b..41b62e8589854 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.test.ts @@ -7,11 +7,12 @@ import { renderHook } from '@testing-library/react-hooks'; import { SecurityPageName } from '../../../app/types'; -import { NavLinkItem } from '../../links/types'; +import { AppLinkItems } from '../../links'; import { TestProviders } from '../../mock'; import { useAppNavLinks, useAppRootNavLink } from './nav_links'; +import { NavLinkItem } from './types'; -const mockNavLinks = [ +const mockNavLinks: AppLinkItems = [ { description: 'description', id: SecurityPageName.administration, @@ -22,6 +23,10 @@ const mockNavLinks = [ links: [], path: '/path_2', title: 'title 2', + sideNavDisabled: true, + landingIcon: 'someicon', + landingImage: 'someimage', + skipUrlState: true, }, ], path: '/path', @@ -30,7 +35,7 @@ const mockNavLinks = [ ]; jest.mock('../../links', () => ({ - getNavLinkItems: () => mockNavLinks, + useAppLinks: () => mockNavLinks, })); const renderUseAppNavLinks = () => @@ -44,11 +49,47 @@ const renderUseAppRootNavLink = (id: SecurityPageName) => describe('useAppNavLinks', () => { it('should return all nav links', () => { const { result } = renderUseAppNavLinks(); - expect(result.current).toEqual(mockNavLinks); + expect(result.current).toMatchInlineSnapshot(` + Array [ + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + }, + ] + `); }); it('should return a root nav links', () => { const { result } = renderUseAppRootNavLink(SecurityPageName.administration); - expect(result.current).toEqual(mockNavLinks[0]); + expect(result.current).toMatchInlineSnapshot(` + Object { + "description": "description", + "id": "administration", + "links": Array [ + Object { + "description": "description 2", + "disabled": true, + "icon": "someicon", + "id": "endpoints", + "image": "someimage", + "skipUrlState": true, + "title": "title 2", + }, + ], + "title": "title", + } + `); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts index efdf72a1f7926..db8b5788b04d6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/nav_links.ts @@ -5,21 +5,35 @@ * 2.0. */ -import { useKibana } from '../../lib/kibana'; -import { useEnableExperimental } from '../../hooks/use_experimental_features'; -import { useLicense } from '../../hooks/use_license'; -import { getNavLinkItems } from '../../links'; +import { useMemo } from 'react'; +import { useAppLinks } from '../../links'; import type { SecurityPageName } from '../../../app/types'; -import type { NavLinkItem } from '../../links/types'; +import { NavLinkItem } from './types'; +import { AppLinkItems } from '../../links/types'; export const useAppNavLinks = (): NavLinkItem[] => { - const license = useLicense(); - const enableExperimental = useEnableExperimental(); - const capabilities = useKibana().services.application.capabilities; - - return getNavLinkItems({ enableExperimental, license, capabilities }); + const appLinks = useAppLinks(); + const navLinks = useMemo(() => formatNavLinkItems(appLinks), [appLinks]); + return navLinks; }; export const useAppRootNavLink = (linkId: SecurityPageName): NavLinkItem | undefined => { return useAppNavLinks().find(({ id }) => id === linkId); }; + +const formatNavLinkItems = (appLinks: AppLinkItems): NavLinkItem[] => + appLinks.map((link) => ({ + id: link.id, + title: link.title, + ...(link.categories != null ? { categories: link.categories } : {}), + ...(link.description != null ? { description: link.description } : {}), + ...(link.sideNavDisabled === true ? { disabled: true } : {}), + ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), + ...(link.landingImage != null ? { image: link.landingImage } : {}), + ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), + ...(link.links && link.links.length + ? { + links: formatNavLinkItems(link.links), + } + : {}), + })); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx new file mode 100644 index 0000000000000..de96338ef98e6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/icons/launch.tsx @@ -0,0 +1,25 @@ +/* + * 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, { SVGProps } from 'react'; + +export const EuiIconLaunch: React.FC> = ({ ...props }) => ( + + + + + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts new file mode 100644 index 0000000000000..a2c866e604e16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { SecuritySideNav } from './security_side_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx new file mode 100644 index 0000000000000..c0ebd0722f725 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.test.tsx @@ -0,0 +1,256 @@ +/* + * 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 } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { SecuritySideNav } from './security_side_nav'; +import { SolutionGroupedNavProps } from '../solution_grouped_nav/solution_grouped_nav'; +import { NavLinkItem } from '../types'; + +const manageNavLink: NavLinkItem = { + id: SecurityPageName.administration, + title: 'manage', + description: 'manage description', + categories: [{ label: 'test category', linkIds: [SecurityPageName.endpoints] }], + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + }, + ], +}; +const alertsNavLink: NavLinkItem = { + id: SecurityPageName.alerts, + title: 'alerts', + description: 'alerts description', +}; + +const mockSolutionGroupedNav = jest.fn((_: SolutionGroupedNavProps) => <>); +jest.mock('../solution_grouped_nav', () => ({ + SolutionGroupedNav: (props: SolutionGroupedNavProps) => mockSolutionGroupedNav(props), +})); +const mockUseRouteSpy = jest.fn(() => [{ pageName: SecurityPageName.alerts }]); +jest.mock('../../../utils/route/use_route_spy', () => ({ + useRouteSpy: () => mockUseRouteSpy(), +})); + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); +jest.mock('../../../links', () => ({ + getAncestorLinksInfo: (id: string) => [{ id }], +})); + +const mockUseAppNavLinks = jest.fn((): NavLinkItem[] => [alertsNavLink, manageNavLink]); +jest.mock('../nav_links', () => ({ + useAppNavLinks: () => mockUseAppNavLinks(), +})); +jest.mock('../../links', () => ({ + useGetSecuritySolutionLinkProps: + () => + ({ deepLinkId }: { deepLinkId: SecurityPageName }) => ({ + href: `/${deepLinkId}`, + }), +})); + +const renderNav = () => + render(, { + wrapper: TestProviders, + }); + +describe('SecuritySideNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render main items', () => { + mockUseAppNavLinks.mockReturnValueOnce([alertsNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith({ + selectedId: SecurityPageName.alerts, + items: [ + { + id: SecurityPageName.alerts, + label: 'alerts', + href: '/alerts', + }, + ], + footerItems: [], + }); + }); + + it('should render the loader if items are still empty', () => { + mockUseAppNavLinks.mockReturnValueOnce([]); + const result = renderNav(); + expect(result.getByTestId('sideNavLoader')).toBeInTheDocument(); + expect(mockSolutionGroupedNav).not.toHaveBeenCalled(); + }); + + it('should render with selected id', () => { + mockUseRouteSpy.mockReturnValueOnce([{ pageName: SecurityPageName.administration }]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + selectedId: SecurityPageName.administration, + }) + ); + }); + + it('should render footer items', () => { + mockUseAppNavLinks.mockReturnValueOnce([manageNavLink]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: SecurityPageName.endpoints, + label: 'title 2', + description: 'description 2', + href: '/endpoints', + }, + ], + }, + ], + }) + ); + }); + + it('should not render disabled items', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { ...alertsNavLink, disabled: true }, + { + ...manageNavLink, + links: [ + { + id: SecurityPageName.endpoints, + title: 'title 2', + description: 'description 2', + disabled: true, + }, + ], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(true); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [ + { + id: hostIsolationExceptionsLink.id, + label: hostIsolationExceptionsLink.title, + description: hostIsolationExceptionsLink.description, + href: '/host_isolation_exceptions', + }, + ], + }, + ], + }) + ); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValue(false); + const hostIsolationExceptionsLink = { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions', + description: 'test description hostIsolationExceptions', + }; + + mockUseAppNavLinks.mockReturnValueOnce([ + { + ...manageNavLink, + links: [hostIsolationExceptionsLink], + }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.administration, + label: 'manage', + href: '/administration', + categories: manageNavLink.categories, + items: [], + }, + ], + }) + ); + }); + + it('should render custom item', () => { + mockUseAppNavLinks.mockReturnValueOnce([ + { id: SecurityPageName.landing, title: 'get started' }, + ]); + renderNav(); + expect(mockSolutionGroupedNav).toHaveBeenCalledWith( + expect.objectContaining({ + items: [], + selectedId: SecurityPageName.alerts, + footerItems: [ + { + id: SecurityPageName.landing, + render: expect.any(Function), + }, + ], + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx new file mode 100644 index 0000000000000..b9173270e381e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx @@ -0,0 +1,156 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { EuiHorizontalRule, EuiListGroupItem, EuiLoadingSpinner } from '@elastic/eui'; +import { SecurityPageName } from '../../../../app/types'; +import { getAncestorLinksInfo } from '../../../links'; +import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { SecuritySolutionLinkAnchor, useGetSecuritySolutionLinkProps } from '../../links'; +import { useAppNavLinks } from '../nav_links'; +import { SolutionGroupedNav } from '../solution_grouped_nav'; +import { CustomSideNavItem, DefaultSideNavItem, SideNavItem } from '../solution_grouped_nav/types'; +import { NavLinkItem } from '../types'; +import { EuiIconLaunch } from './icons/launch'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../../../management/pages/host_isolation_exceptions/view/hooks'; + +const isFooterNavItem = (id: SecurityPageName) => + id === SecurityPageName.landing || id === SecurityPageName.administration; + +type FormatSideNavItems = (navItems: NavLinkItem) => SideNavItem; + +/** + * Renders the navigation item for "Get Started" custom link + */ +const GetStartedCustomLinkComponent: React.FC<{ + isSelected: boolean; + title: string; +}> = ({ isSelected, title }) => ( + + + + +); +const GetStartedCustomLink = React.memo(GetStartedCustomLinkComponent); + +/** + * Returns a function to format generic `NavLinkItem` array to the `SideNavItem` type + */ +const useFormatSideNavItem = (): FormatSideNavItems => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); // adds href and onClick props + + const formatSideNavItem: FormatSideNavItems = useCallback( + (navLinkItem) => { + const formatDefaultItem = (navItem: NavLinkItem): DefaultSideNavItem => ({ + id: navItem.id, + label: navItem.title, + ...getSecuritySolutionLinkProps({ deepLinkId: navItem.id }), + ...(navItem.categories && navItem.categories.length > 0 + ? { categories: navItem.categories } + : {}), + ...(navItem.links && navItem.links.length > 0 + ? { + items: navItem.links + .filter( + (link) => + !link.disabled && + !( + link.id === SecurityPageName.hostIsolationExceptions && + hideHostIsolationExceptions + ) + ) + .map((panelNavItem) => ({ + id: panelNavItem.id, + label: panelNavItem.title, + description: panelNavItem.description, + ...getSecuritySolutionLinkProps({ deepLinkId: panelNavItem.id }), + })), + } + : {}), + }); + + const formatGetStartedItem = (navItem: NavLinkItem): CustomSideNavItem => ({ + id: navItem.id, + render: (isSelected) => ( + + ), + }); + + if (navLinkItem.id === SecurityPageName.landing) { + return formatGetStartedItem(navLinkItem); + } + return formatDefaultItem(navLinkItem); + }, + [getSecuritySolutionLinkProps, hideHostIsolationExceptions] + ); + + return formatSideNavItem; +}; + +/** + * Returns the formatted `items` and `footerItems` to be rendered in the navigation + */ +const useSideNavItems = () => { + const appNavLinks = useAppNavLinks(); + const formatSideNavItem = useFormatSideNavItem(); + + const sideNavItems = useMemo(() => { + const mainNavItems: SideNavItem[] = []; + const footerNavItems: SideNavItem[] = []; + appNavLinks.forEach((appNavLink) => { + if (appNavLink.disabled) { + return; + } + + if (isFooterNavItem(appNavLink.id)) { + footerNavItems.push(formatSideNavItem(appNavLink)); + } else { + mainNavItems.push(formatSideNavItem(appNavLink)); + } + }); + return [mainNavItems, footerNavItems]; + }, [appNavLinks, formatSideNavItem]); + + return sideNavItems; +}; + +const useSelectedId = (): SecurityPageName => { + const [{ pageName }] = useRouteSpy(); + const selectedId = useMemo(() => { + const [rootLinkInfo] = getAncestorLinksInfo(pageName as SecurityPageName); + return rootLinkInfo?.id ?? ''; + }, [pageName]); + + return selectedId; +}; + +/** + * Main security navigation component. + * It takes the links to render from the generic application `links` configs. + */ +export const SecuritySideNav: React.FC = () => { + const [items, footerItems] = useSideNavItems(); + const selectedId = useSelectedId(); + + if (items.length === 0 && footerItems.length === 0) { + return ; + } + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx index f141264bd97e4..e41b566bbc7c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx @@ -9,15 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { NavItem } from './solution_grouped_nav_item'; import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav'; +import { SideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: NavItem[] = [ +const mockItems: SideNavItem[] = [ { id: SecurityPageName.dashboardsLanding, label: 'Dashboards', diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx index fcfcc9d6b1b4b..073723b80f518 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx @@ -15,22 +15,38 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; -import { SolutionGroupedNavPanel } from './solution_grouped_nav_panel'; +import { SolutionNavPanel } from './solution_grouped_nav_panel'; import { EuiListGroupItemStyled } from './solution_grouped_nav.styles'; -import { - isCustomNavItem, - isDefaultNavItem, - NavItem, - PortalNavItem, -} from './solution_grouped_nav_item'; +import { DefaultSideNavItem, SideNavItem, isCustomItem, isDefaultItem } from './types'; import { EuiIconSpaces } from './icons/spaces'; +import type { LinkCategories } from '../../../links'; export interface SolutionGroupedNavProps { - items: NavItem[]; + items: SideNavItem[]; + selectedId: string; + footerItems?: SideNavItem[]; +} +export interface SolutionNavItemsProps { + items: SideNavItem[]; selectedId: string; - footerItems?: NavItem[]; + activePanelNavId: ActivePanelNav; + isMobileSize: boolean; + navItemsById: NavItemsById; + onOpenPanelNav: (id: string) => void; } -type ActivePortalNav = string | null; +export interface SolutionNavItemProps { + item: SideNavItem; + isSelected: boolean; + isActive: boolean; + hasPanelNav: boolean; + onOpenPanelNav: (id: string) => void; +} + +type ActivePanelNav = string | null; +type NavItemsById = Record< + string, + { title: string; panelItems: DefaultSideNavItem[]; categories?: LinkCategories } +>; export const SolutionGroupedNavComponent: React.FC = ({ items, @@ -39,41 +55,40 @@ export const SolutionGroupedNavComponent: React.FC = ({ }) => { const isMobileSize = useIsWithinBreakpoints(['xs', 's']); - const [activePortalNavId, setActivePortalNavId] = useState(null); - const activePortalNavIdRef = useRef(null); + const [activePanelNavId, setActivePanelNavId] = useState(null); + const activePanelNavIdRef = useRef(null); - const openPortalNav = (navId: string) => { - activePortalNavIdRef.current = navId; - setActivePortalNavId(navId); + const openPanelNav = (id: string) => { + activePanelNavIdRef.current = id; + setActivePanelNavId(id); }; - const closePortalNav = () => { - activePortalNavIdRef.current = null; - setActivePortalNavId(null); - }; + const onClosePanelNav = useCallback(() => { + activePanelNavIdRef.current = null; + setActivePanelNavId(null); + }, []); - const onClosePortalNav = useCallback(() => { - const currentPortalNavId = activePortalNavIdRef.current; + const onOutsidePanelClick = useCallback(() => { + const currentPanelNavId = activePanelNavIdRef.current; setTimeout(() => { // This event is triggered on outside click. // Closing the side nav at the end of event loop to make sure it - // closes also if the active "nav group" button has been clicked (toggle), - // but it does not close if any some other "nav group" open button has been clicked. - if (activePortalNavIdRef.current === currentPortalNavId) { - closePortalNav(); + // closes also if the active panel button has been clicked (toggle), + // but it does not close if any any other panel open button has been clicked. + if (activePanelNavIdRef.current === currentPanelNavId) { + onClosePanelNav(); } }); - }, []); + }, [onClosePanelNav]); - const navItemsById = useMemo( + const navItemsById = useMemo( () => - [...items, ...footerItems].reduce< - Record - >((acc, navItem) => { - if (isDefaultNavItem(navItem) && navItem.items && navItem.items.length > 0) { + [...items, ...footerItems].reduce((acc, navItem) => { + if (isDefaultItem(navItem) && navItem.items && navItem.items.length > 0) { acc[navItem.id] = { title: navItem.label, - subItems: navItem.items, + panelItems: navItem.items, + categories: navItem.categories, }; } return acc; @@ -82,67 +97,20 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); const portalNav = useMemo(() => { - if (activePortalNavId == null || !navItemsById[activePortalNavId]) { + if (activePanelNavId == null || !navItemsById[activePanelNavId]) { return null; } - const { subItems, title } = navItemsById[activePortalNavId]; - return ; - }, [activePortalNavId, navItemsById, onClosePortalNav]); - - const renderNavItem = useCallback( - (navItem: NavItem) => { - if (isCustomNavItem(navItem)) { - return {navItem.render()}; - } - const { id, href, label, onClick } = navItem; - const isActive = activePortalNavId === id; - const isCurrentNav = selectedId === id; - - const itemClassNames = classNames('solutionGroupedNavItem', { - 'solutionGroupedNavItem--isActive': isActive, - 'solutionGroupedNavItem--isPrimary': isCurrentNav, - }); - const buttonClassNames = classNames('solutionGroupedNavItemButton'); - - return ( - // eslint-disable-next-line @elastic/eui/href-or-on-click - - { - ev.preventDefault(); - ev.stopPropagation(); - openPortalNav(id); - }, - iconType: EuiIconSpaces, - iconSize: 'm', - 'aria-label': 'Toggle group nav', - 'data-test-subj': `groupedNavItemButton-${id}`, - alwaysShow: true, - }, - } - : {})} - /> - - ); - }, - [activePortalNavId, isMobileSize, navItemsById, selectedId] - ); + const { panelItems, title, categories } = navItemsById[activePanelNavId]; + return ( + + ); + }, [activePanelNavId, navItemsById, onClosePanelNav, onOutsidePanelClick]); return ( <> @@ -150,10 +118,28 @@ export const SolutionGroupedNavComponent: React.FC = ({ - {items.map(renderNavItem)} + + + - {footerItems.map(renderNavItem)} + + + @@ -163,5 +149,84 @@ export const SolutionGroupedNavComponent: React.FC = ({ ); }; - export const SolutionGroupedNav = React.memo(SolutionGroupedNavComponent); + +const SolutionNavItems: React.FC = ({ + items, + selectedId, + activePanelNavId, + isMobileSize, + navItemsById, + onOpenPanelNav, +}) => ( + <> + {items.map((item) => ( + + ))} + +); + +const SolutionNavItemComponent: React.FC = ({ + item, + isSelected, + isActive, + hasPanelNav, + onOpenPanelNav, +}) => { + if (isCustomItem(item)) { + return {item.render(isSelected)}; + } + const { id, href, label, onClick } = item; + + const itemClassNames = classNames('solutionGroupedNavItem', { + 'solutionGroupedNavItem--isActive': isActive, + 'solutionGroupedNavItem--isPrimary': isSelected, + }); + const buttonClassNames = classNames('solutionGroupedNavItemButton'); + + const onButtonClick: React.MouseEventHandler = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + onOpenPanelNav(id); + }; + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + + ); +}; +const SolutionNavItem = React.memo(SolutionNavItemComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx deleted file mode 100644 index df7e08ad46f95..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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 { useGetSecuritySolutionLinkProps } from '../../links'; -import { SecurityPageName } from '../../../../../common/constants'; - -export type NavItemCategories = Array<{ label: string; itemIds: string[] }>; -export interface DefaultNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - items?: PortalNavItem[]; - categories?: NavItemCategories; -} - -export interface CustomNavItem { - id: string; - render: () => React.ReactNode; -} - -export type NavItem = DefaultNavItem | CustomNavItem; - -export interface PortalNavItem { - id: string; - label: string; - href: string; - onClick?: React.MouseEventHandler; - description?: string; -} - -export const isCustomNavItem = (navItem: NavItem): navItem is CustomNavItem => 'render' in navItem; -export const isDefaultNavItem = (navItem: NavItem): navItem is DefaultNavItem => - !isCustomNavItem(navItem); - -export const useNavItems: () => NavItem[] = () => { - const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); - return [ - { - id: SecurityPageName.dashboardsLanding, - label: 'Dashboards', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.dashboardsLanding }), - items: [ - { - id: 'overview', - label: 'Overview', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.overview }), - }, - { - id: 'detection_response', - label: 'Detection & Response', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.detectionAndResponse }), - }, - // TODO: add the cloudPostureFindings to the config here - // { - // id: SecurityPageName.cloudPostureFindings, - // label: 'Cloud Posture Findings', - // description: 'The description goes here', - // ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.cloudPostureFindings }), - // }, - ], - }, - { - id: SecurityPageName.alerts, - label: 'Alerts', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.alerts }), - }, - { - id: SecurityPageName.timelines, - label: 'Timelines', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.timelines }), - }, - { - id: SecurityPageName.case, - label: 'Cases', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.case }), - }, - { - id: SecurityPageName.threatHuntingLanding, - label: 'Threat Hunting', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.threatHuntingLanding }), - items: [ - { - id: SecurityPageName.hosts, - label: 'Hosts', - description: - 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hosts }), - }, - { - id: SecurityPageName.network, - label: 'Network', - description: - 'The action or process of interacting with others to exchange information and develop professional or social contacts.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.network }), - }, - { - id: SecurityPageName.users, - label: 'Users', - description: 'Sudo commands dashboard from the Logs System integration.', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.users }), - }, - ], - }, - // TODO: implement footer and move management - { - id: SecurityPageName.administration, - label: 'Manage', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.administration }), - categories: [ - { label: 'SIEM', itemIds: [SecurityPageName.rules, SecurityPageName.exceptions] }, - { - label: 'ENDPOINTS', - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, - ], - items: [ - { - id: SecurityPageName.rules, - label: 'Rules', - description: 'The description here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.rules }), - }, - { - id: SecurityPageName.exceptions, - label: 'Exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.exceptions }), - }, - { - id: SecurityPageName.endpoints, - label: 'Endpoints', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.endpoints }), - }, - { - id: SecurityPageName.policies, - label: 'Policies', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.policies }), - }, - { - id: SecurityPageName.trustedApps, - label: 'Trusted applications', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.trustedApps }), - }, - { - id: SecurityPageName.eventFilters, - label: 'Event filters', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.eventFilters }), - }, - { - id: SecurityPageName.blocklist, - label: 'Blocklist', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.blocklist }), - }, - { - id: SecurityPageName.hostIsolationExceptions, - label: 'Host Isolation IP exceptions', - description: 'The description goes here', - ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hostIsolationExceptions }), - }, - ], - }, - ]; -}; - -export const useFooterNavItems: () => NavItem[] = () => { - // TODO: implement footer items - return []; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx index 93d46c35d6bed..8215d9c0b9f40 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx @@ -9,18 +9,15 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; import { SecurityPageName } from '../../../../app/types'; import { TestProviders } from '../../../mock'; -import { PortalNavItem } from './solution_grouped_nav_item'; -import { - SolutionGroupedNavPanel, - SolutionGroupedNavPanelProps, -} from './solution_grouped_nav_panel'; +import { SolutionNavPanel, SolutionNavPanelProps } from './solution_grouped_nav_panel'; +import { DefaultSideNavItem } from './types'; const mockUseShowTimeline = jest.fn((): [boolean] => [false]); jest.mock('../../../utils/timeline/use_show_timeline', () => ({ useShowTimeline: () => mockUseShowTimeline(), })); -const mockItems: PortalNavItem[] = [ +const mockItems: DefaultSideNavItem[] = [ { id: SecurityPageName.hosts, label: 'Hosts', @@ -37,14 +34,16 @@ const mockItems: PortalNavItem[] = [ const PANEL_TITLE = 'test title'; const mockOnClose = jest.fn(); -const renderNavPanel = (props: Partial = {}) => +const mockOnOutsideClick = jest.fn(); +const renderNavPanel = (props: Partial = {}) => render( <>
- , @@ -112,7 +111,7 @@ describe('SolutionGroupedNav', () => { const result = renderNavPanel(); result.getByTestId('outsideClickDummy').click(); waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnOutsideClick).toHaveBeenCalled(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx index c1615a97264eb..a418f666d2782 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -13,8 +13,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFocusTrap, + EuiHorizontalRule, EuiOutsideClickDetector, EuiPortal, + EuiSpacer, EuiTitle, EuiWindowEvent, keys, @@ -22,18 +24,39 @@ import { } from '@elastic/eui'; import classNames from 'classnames'; import { EuiPanelStyled } from './solution_grouped_nav_panel.styles'; -import { PortalNavItem } from './solution_grouped_nav_item'; import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; +import type { DefaultSideNavItem } from './types'; +import type { LinkCategories } from '../../../links/types'; -export interface SolutionGroupedNavPanelProps { +export interface SolutionNavPanelProps { onClose: () => void; + onOutsideClick: () => void; title: string; - items: PortalNavItem[]; + items: DefaultSideNavItem[]; + categories?: LinkCategories; +} +export interface SolutionNavPanelCategoriesProps { + categories: LinkCategories; + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemsProps { + items: DefaultSideNavItem[]; + onClose: () => void; +} +export interface SolutionNavPanelItemProps { + item: DefaultSideNavItem; + onClose: () => void; } -const SolutionGroupedNavPanelComponent: React.FC = ({ +/** + * Renders the side navigation panel for secondary links + */ +const SolutionNavPanelComponent: React.FC = ({ onClose, + onOutsideClick, title, + categories, items, }) => { const [hasTimelineBar] = useShowTimeline(); @@ -41,9 +64,7 @@ const SolutionGroupedNavPanelComponent: React.FC = const isTimelineVisible = hasTimelineBar && isLargerBreakpoint; const panelClasses = classNames('eui-yScroll'); - /** - * ESC key closes SideNav - */ + // ESC key closes PanelNav const onKeyDown = useCallback( (ev: KeyboardEvent) => { if (ev.key === keys.ESCAPE) { @@ -58,7 +79,7 @@ const SolutionGroupedNavPanelComponent: React.FC = - onClose()}> + = - {items.map(({ id, href, onClick, label, description }: PortalNavItem) => ( - - - { - onClose(); - if (onClick) { - onClick(ev); - } - }} - > - {label} - - - {description} - - ))} + {categories ? ( + + ) : ( + + )} @@ -105,5 +116,61 @@ const SolutionGroupedNavPanelComponent: React.FC = ); }; +export const SolutionNavPanel = React.memo(SolutionNavPanelComponent); + +const SolutionNavPanelCategories: React.FC = ({ + categories, + items, + onClose, +}) => { + const itemsMap = new Map(items.map((item) => [item.id, item])); + + return ( + <> + {categories.map(({ label, linkIds }) => { + const links = linkIds.reduce((acc, linkId) => { + const link = itemsMap.get(linkId); + if (link) { + acc.push(link); + } + return acc; + }, []); + + return ( + + +

{label}

+
+ + + +
+ ); + })} + + ); +}; -export const SolutionGroupedNavPanel = React.memo(SolutionGroupedNavPanelComponent); +const SolutionNavPanelItems: React.FC = ({ items, onClose }) => ( + <> + {items.map(({ id, href, onClick, label, description }) => ( + + + { + onClose(); + if (onClick) { + onClick(ev); + } + }} + > + {label} + + + {description} + + ))} + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts new file mode 100644 index 0000000000000..a16bad9126d09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/types.ts @@ -0,0 +1,32 @@ +/* + * 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 type { SecurityPageName } from '../../../../app/types'; +import type { LinkCategories } from '../../../links/types'; + +export interface DefaultSideNavItem { + id: SecurityPageName; + label: string; + href: string; + onClick?: React.MouseEventHandler; + description?: string; + items?: DefaultSideNavItem[]; + categories?: LinkCategories; +} + +export interface CustomSideNavItem { + id: string; + render: (isSelected: boolean) => React.ReactNode; +} + +export type SideNavItem = DefaultSideNavItem | CustomSideNavItem; + +export const isCustomItem = (navItem: SideNavItem): navItem is CustomSideNavItem => + 'render' in navItem; +export const isDefaultItem = (navItem: SideNavItem): navItem is DefaultSideNavItem => + !isCustomItem(navItem); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 91edd1feea2da..85d504165484b 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -5,10 +5,12 @@ * 2.0. */ +import { IconType } from '@elastic/eui'; import { UrlStateType } from '../url_state/constants'; import { SecurityPageName } from '../../../app/types'; import { UrlState } from '../url_state/types'; import { SiemRouteType } from '../../utils/route/types'; +import { LinkCategories } from '../../links'; export interface TabNavigationComponentProps { pageName: string; @@ -76,10 +78,14 @@ export type GetUrlForApp = ( ) => string; export type NavigateToUrl = (url: string) => void; - -export interface NavigationCategory { - label: string; - linkIds: readonly SecurityPageName[]; +export interface NavLinkItem { + categories?: LinkCategories; + description?: string; + disabled?: boolean; + icon?: IconType; + id: SecurityPageName; + links?: NavLinkItem[]; + image?: string; + title: string; + skipUrlState?: boolean; } - -export type NavigationCategories = Readonly; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap index cadb9057ccbcc..d50b07ca56089 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/__snapshots__/index.test.tsx.snap @@ -14,7 +14,7 @@ Object { "href": "securitySolutionUI/get_started?query=(language:kuery,query:'host.name:%22security-solution-es%22')&sourcerer=()&timerange=(global:(linkTo:!(timeline),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)),timeline:(linkTo:!(global),timerange:(from:'2020-07-07T08:20:18.966Z',fromStr:now-24h,kind:relative,to:'2020-07-08T08:20:18.966Z',toStr:now)))", "id": "get_started", "isSelected": false, - "name": "Getting started", + "name": "Get started", "onClick": [Function], }, Object { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index 1dbcf929ed81f..1123fd50a53e6 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -11,9 +11,8 @@ import { i18n } from '@kbn/i18n'; import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; -import { SolutionGroupedNav } from '../solution_grouped_nav'; -import { useNavItems } from '../solution_grouped_nav/solution_grouped_nav_item'; import { useIsGroupedNavigationEnabled } from '../helpers'; +import { SecuritySideNav } from '../security_side_nav'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -48,7 +47,6 @@ export const usePrimaryNavigation = ({ // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) }, [pageName, navTabs, mapLocationToTab, selectedTabId]); - const navLinkItems = useNavItems(); const navItems = usePrimaryNavigationItems({ navTabs, selectedTabId, @@ -65,7 +63,7 @@ export const usePrimaryNavigation = ({ icon: 'logoSecurity', ...(isGroupedNavigationEnabled ? { - children: , + children: , closeFlyoutButtonPosition: 'inside', } : { items: navItems }), diff --git a/x-pack/plugins/security_solution/public/common/links/app_links.ts b/x-pack/plugins/security_solution/public/common/links/app_links.ts index 1a78444012334..45a7ed373222f 100644 --- a/x-pack/plugins/security_solution/public/common/links/app_links.ts +++ b/x-pack/plugins/security_solution/public/common/links/app_links.ts @@ -4,48 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { i18n } from '@kbn/i18n'; -import { SecurityPageName, THREAT_HUNTING_PATH } from '../../../common/constants'; -import { THREAT_HUNTING } from '../../app/translations'; -import { FEATURE, LinkItem, UserPermissions } from './types'; -import { links as hostsLinks } from '../../hosts/links'; +import { CoreStart } from '@kbn/core/public'; +import { AppLinkItems } from './types'; import { links as detectionLinks } from '../../detections/links'; -import { links as networkLinks } from '../../network/links'; -import { links as usersLinks } from '../../users/links'; import { links as timelinesLinks } from '../../timelines/links'; import { getCasesLinkItems } from '../../cases/links'; -import { links as managementLinks } from '../../management/links'; -import { gettingStartedLinks, dashboardsLandingLinks } from '../../overview/links'; +import { getManagementLinkItems } from '../../management/links'; +import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links'; +import { gettingStartedLinks } from '../../overview/links'; +import { StartPlugins } from '../../types'; -export const appLinks: Readonly = Object.freeze([ - gettingStartedLinks, - dashboardsLandingLinks, - detectionLinks, - { - id: SecurityPageName.threatHuntingLanding, - title: THREAT_HUNTING, - path: THREAT_HUNTING_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.threatHunting', { - defaultMessage: 'Threat hunting', - }), - ], - links: [hostsLinks, networkLinks, usersLinks], - skipUrlState: true, - hideTimeline: true, - }, - timelinesLinks, - getCasesLinkItems(), - managementLinks, -]); +export const getAppLinks = async ( + core: CoreStart, + plugins: StartPlugins +): Promise => { + const managementLinks = await getManagementLinkItems(core, plugins); + const casesLinks = getCasesLinkItems(); -export const getAppLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions) => { - // OLM team, implement async behavior here - return appLinks; + return Object.freeze([ + dashboardsLandingLinks, + detectionLinks, + timelinesLinks, + casesLinks, + threatHuntingLandingLinks, + gettingStartedLinks, + managementLinks, + ]); }; diff --git a/x-pack/plugins/security_solution/public/common/links/index.tsx b/x-pack/plugins/security_solution/public/common/links/index.tsx index 6d8e99cd416d2..e4e4de0b49430 100644 --- a/x-pack/plugins/security_solution/public/common/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/links/index.tsx @@ -6,3 +6,4 @@ */ export * from './links'; +export * from './types'; diff --git a/x-pack/plugins/security_solution/public/common/links/links.test.ts b/x-pack/plugins/security_solution/public/common/links/links.test.ts index b68ae3d863de3..896f9357077c8 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.test.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.test.ts @@ -5,399 +5,223 @@ * 2.0. */ +import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { Capabilities } from '@kbn/core/types'; +import { mockGlobalState, TestProviders } from '../mock'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; +import { AppLinkItems } from './types'; +import { act, renderHook } from '@testing-library/react-hooks'; import { + useAppLinks, getAncestorLinksInfo, - getDeepLinks, - getInitialDeepLinks, getLinkInfo, - getNavLinkItems, needsUrlState, + updateAppLinks, + excludeAppLink, } from './links'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; -import { Capabilities } from '@kbn/core/types'; -import { AppDeepLink } from '@kbn/core/public'; -import { mockGlobalState } from '../mock'; -import { NavLinkItem } from './types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; -import { LicenseService } from '../../../common/license'; + +const defaultAppLinks: AppLinkItems = [ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: `/hosts/events`, + skipUrlState: true, + }, + ], + }, +]; const mockExperimentalDefaults = mockGlobalState.app.enableExperimental; + const mockCapabilities = { [CASES_FEATURE_ID]: { read_cases: true, crud_cases: true }, [SERVER_APP_ID]: { show: true }, } as unknown as Capabilities; -const findDeepLink = (id: string, deepLinks: AppDeepLink[]): AppDeepLink | null => - deepLinks.reduce((deepLinkFound: AppDeepLink | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.deepLinks) { - return findDeepLink(id, deepLink.deepLinks); - } - return null; - }, null); - -const findNavLink = (id: SecurityPageName, navLinks: NavLinkItem[]): NavLinkItem | null => - navLinks.reduce((deepLinkFound: NavLinkItem | null, deepLink) => { - if (deepLinkFound !== null) { - return deepLinkFound; - } - if (deepLink.id === id) { - return deepLink; - } - if (deepLink.links) { - return findNavLink(id, deepLink.links); - } - return null; - }, null); - -// remove filter once new nav is live -const allPages = Object.values(SecurityPageName).filter( - (pageName) => - pageName !== SecurityPageName.explore && - pageName !== SecurityPageName.detections && - pageName !== SecurityPageName.investigate -); -const casesPages = [ - SecurityPageName.case, - SecurityPageName.caseConfigure, - SecurityPageName.caseCreate, -]; -const featureFlagPages = [ - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsAuthentications, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const premiumPages = [ - SecurityPageName.caseConfigure, - SecurityPageName.hostsAnomalies, - SecurityPageName.networkAnomalies, - SecurityPageName.usersAnomalies, - SecurityPageName.detectionAndResponse, - SecurityPageName.hostsRisk, - SecurityPageName.usersRisk, -]; -const nonCasesPages = allPages.reduce( - (acc: SecurityPageName[], p) => - casesPages.includes(p) || featureFlagPages.includes(p) ? acc : [p, ...acc], - [] -); - const licenseBasicMock = jest.fn().mockImplementation((arg: LicenseType) => arg === 'basic'); const licensePremiumMock = jest.fn().mockReturnValue(true); const mockLicense = { - isAtLeast: licensePremiumMock, -} as unknown as LicenseService; - -const threatHuntingLinkInfo = { - features: ['siem.show'], - globalNavEnabled: false, - globalSearchKeywords: ['Threat hunting'], - id: 'threat_hunting', - path: '/threat_hunting', - title: 'Threat Hunting', - hideTimeline: true, - skipUrlState: true, -}; + hasAtLeast: licensePremiumMock, +} as unknown as ILicense; -const hostsLinkInfo = { - globalNavEnabled: true, - globalNavOrder: 9002, - globalSearchEnabled: true, - globalSearchKeywords: ['Hosts'], - id: 'hosts', - path: '/hosts', - title: 'Hosts', - landingImage: 'test-file-stub', - description: 'A comprehensive overview of all hosts and host-related security events.', -}; +const renderUseAppLinks = () => + renderHook<{}, AppLinkItems>(() => useAppLinks(), { wrapper: TestProviders }); -describe('security app link helpers', () => { +describe('Security app links', () => { beforeEach(() => { - mockLicense.isAtLeast = licensePremiumMock; - }); - describe('getInitialDeepLinks', () => { - it('should return all pages in the app', () => { - const links = getInitialDeepLinks(); - allPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - }); - describe('getDeepLinks', () => { - it('basicLicense should return only basic links', async () => { - mockLicense.isAtLeast = licenseBasicMock; + mockLicense.hasAtLeast = licensePremiumMock; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', async () => { - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findDeepLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', async () => { - const links = await getDeepLinks({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findDeepLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findDeepLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); + updateAppLinks(defaultAppLinks, { + capabilities: mockCapabilities, + experimentalFeatures: mockExperimentalDefaults, + license: mockLicense, }); + }); - it('Removes siem features when siem capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findDeepLink(page, links)).toBeTruthy(); - } - return expect(findDeepLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', async () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = await getDeepLinks({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findDeepLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findDeepLink(page, links)).toBeFalsy()); + describe('useAppLinks', () => { + it('should return initial appLinks', () => { + const { result } = renderUseAppLinks(); + expect(result.current).toStrictEqual(defaultAppLinks); + }); + + it('should filter not allowed links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + // this link should not be excluded, the test checks all conditions are passed + const networkLinkItem = { + id: SecurityPageName.network, + title: 'Network', + path: '/network', + capabilities: [`${CASES_FEATURE_ID}.read_cases`, `${SERVER_APP_ID}.show`], + experimentalKey: 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + hideWhenExperimentalKey: 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + licenseType: 'basic' as const, + }; + + await act(async () => { + updateAppLinks( + [ + { + ...networkLinkItem, + // all its links should be filtered for all different criteria + links: [ + { + id: SecurityPageName.networkExternalAlerts, + title: 'external alerts', + path: '/external_alerts', + experimentalKey: + 'flagDisabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkDns, + title: 'dns', + path: '/dns', + hideWhenExperimentalKey: + 'flagEnabled' as unknown as keyof typeof mockExperimentalDefaults, + }, + { + id: SecurityPageName.networkAnomalies, + title: 'Anomalies', + path: '/anomalies', + capabilities: [ + `${CASES_FEATURE_ID}.read_cases`, + `${CASES_FEATURE_ID}.write_cases`, + ], + }, + { + id: SecurityPageName.networkHttp, + title: 'Http', + path: '/http', + licenseType: 'gold', + }, + ], + }, + { + // should be excluded by license with all its links + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + licenseType: 'platinum', + links: [ + { + id: SecurityPageName.hostsEvents, + title: 'Events', + path: '/events', + }, + ], + }, + ], + { + capabilities: { + ...mockCapabilities, + [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, + }, + experimentalFeatures: { + flagEnabled: true, + flagDisabled: false, + } as unknown as typeof mockExperimentalDefaults, + license: { hasAtLeast: licenseBasicMock } as unknown as ILicense, + } + ); + await waitForNextUpdate(); + }); + + expect(result.current).toStrictEqual([networkLinkItem]); }); }); - describe('getNavLinkItems', () => { - it('basicLicense should return only basic links', () => { - mockLicense.isAtLeast = licenseBasicMock; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAnomalies, links)).toBeFalsy(); - allPages.forEach((page) => { - if (premiumPages.includes(page)) { - return expect(findNavLink(page, links)).toBeFalsy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('platinumLicense should return all links', () => { - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities: mockCapabilities, - }); - allPages.forEach((page) => { - if (premiumPages.includes(page) && !featureFlagPages.includes(page)) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - if (featureFlagPages.includes(page)) { - // ignore feature flag pages - return; - } - expect(findNavLink(page, links)).toBeTruthy(); - }); - }); - it('hideWhenExperimentalKey hides entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: true }, - license: mockLicense, - capabilities: mockCapabilities, + describe('excludeAppLink', () => { + it('should exclude link from app links', async () => { + const { result, waitForNextUpdate } = renderUseAppLinks(); + await act(async () => { + excludeAppLink(SecurityPageName.hostsEvents); + await waitForNextUpdate(); }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeFalsy(); - }); - it('hideWhenExperimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { ...mockExperimentalDefaults, usersEnabled: false }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsAuthentications, links)).toBeTruthy(); - }); - it('experimentalKey shows entry when key = false', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: false, - riskyUsersEnabled: false, - detectionResponseEnabled: false, - }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeFalsy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeFalsy(); - }); - it('experimentalKey shows entry when key = true', () => { - const links = getNavLinkItems({ - enableExperimental: { - ...mockExperimentalDefaults, - riskyHostsEnabled: true, - riskyUsersEnabled: true, - detectionResponseEnabled: true, + expect(result.current).toStrictEqual([ + { + id: SecurityPageName.hosts, + title: 'Hosts', + path: '/hosts', + links: [ + { + id: SecurityPageName.hostsAuthentications, + title: 'Authentications', + path: `/hosts/authentications`, + }, + ], }, - license: mockLicense, - capabilities: mockCapabilities, - }); - expect(findNavLink(SecurityPageName.hostsRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.usersRisk, links)).toBeTruthy(); - expect(findNavLink(SecurityPageName.detectionAndResponse, links)).toBeTruthy(); - }); - - it('Removes siem features when siem capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [SERVER_APP_ID]: { show: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => { - // investigate is active for both Cases and Timelines pages - if (page === SecurityPageName.investigate) { - return expect(findNavLink(page, links)).toBeTruthy(); - } - return expect(findNavLink(page, links)).toBeFalsy(); - }); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - }); - it('Removes cases features when cases capabilities are false', () => { - const capabilities = { - ...mockCapabilities, - [CASES_FEATURE_ID]: { read_cases: false, crud_cases: false }, - } as unknown as Capabilities; - const links = getNavLinkItems({ - enableExperimental: mockExperimentalDefaults, - license: mockLicense, - capabilities, - }); - nonCasesPages.forEach((page) => expect(findNavLink(page, links)).toBeTruthy()); - casesPages.forEach((page) => expect(findNavLink(page, links)).toBeFalsy()); + ]); }); }); describe('getAncestorLinksInfo', () => { - it('finds flattened links for hosts', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.hosts); - expect(hierarchy).toEqual([threatHuntingLinkInfo, hostsLinkInfo]); - }); - it('finds flattened links for uncommonProcesses', () => { - const hierarchy = getAncestorLinksInfo(SecurityPageName.uncommonProcesses); - expect(hierarchy).toEqual([ - threatHuntingLinkInfo, - hostsLinkInfo, + it('should find ancestors flattened links', () => { + const hierarchy = getAncestorLinksInfo(SecurityPageName.hostsEvents); + expect(hierarchy).toStrictEqual([ { - id: 'uncommon_processes', - path: '/hosts/uncommonProcesses', - title: 'Uncommon Processes', + id: SecurityPageName.hosts, + path: '/hosts', + title: 'Hosts', + }, + { + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', }, ]); }); }); describe('needsUrlState', () => { - it('returns true when url state exists for page', () => { + it('should return true when url state exists for page', () => { const needsUrl = needsUrlState(SecurityPageName.hosts); expect(needsUrl).toEqual(true); }); - it('returns false when url state does not exist for page', () => { - const needsUrl = needsUrlState(SecurityPageName.landing); + it('should return false when url state does not exist for page', () => { + const needsUrl = needsUrlState(SecurityPageName.hostsEvents); expect(needsUrl).toEqual(false); }); }); describe('getLinkInfo', () => { - it('gets information for an individual link', () => { - const linkInfo = getLinkInfo(SecurityPageName.hosts); - expect(linkInfo).toEqual(hostsLinkInfo); + it('should get information for an individual link', () => { + const linkInfo = getLinkInfo(SecurityPageName.hostsEvents); + expect(linkInfo).toStrictEqual({ + id: SecurityPageName.hostsEvents, + path: '/hosts/events', + skipUrlState: true, + title: 'Events', + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/links/links.ts b/x-pack/plugins/security_solution/public/common/links/links.ts index 57965bdeba0c0..384861a9dc5e7 100644 --- a/x-pack/plugins/security_solution/public/common/links/links.ts +++ b/x-pack/plugins/security_solution/public/common/links/links.ts @@ -5,169 +5,120 @@ * 2.0. */ -import { AppDeepLink, AppNavLinkStatus, Capabilities } from '@kbn/core/public'; +import type { Capabilities } from '@kbn/core/public'; import { get } from 'lodash'; +import { useEffect, useState } from 'react'; +import { BehaviorSubject } from 'rxjs'; import { SecurityPageName } from '../../../common/constants'; -import { appLinks, getAppLinks } from './app_links'; -import { - Feature, +import type { + AppLinkItems, LinkInfo, LinkItem, - NavLinkItem, NormalizedLink, NormalizedLinks, - UserPermissions, + LinksPermissions, } from './types'; -const createDeepLink = (link: LinkItem, linkProps?: UserPermissions): AppDeepLink => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.links && link.links.length - ? { - deepLinks: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createDeepLink, - }), - } - : {}), - ...(link.globalSearchKeywords != null ? { keywords: link.globalSearchKeywords } : {}), - ...(link.globalNavEnabled != null - ? { navLinkStatus: link.globalNavEnabled ? AppNavLinkStatus.visible : AppNavLinkStatus.hidden } - : {}), - ...(link.globalNavOrder != null ? { order: link.globalNavOrder } : {}), - ...(link.globalSearchEnabled != null ? { searchable: link.globalSearchEnabled } : {}), +/** + * App links updater, it keeps the value of the app links in sync with all application. + * It can be updated using `updateAppLinks` or `excludeAppLink` + * Read it using `subscribeAppLinks` or `useAppLinks` hook. + */ +const appLinksUpdater$ = new BehaviorSubject<{ + links: AppLinkItems; + normalizedLinks: NormalizedLinks; +}>({ + links: [], // stores the appLinkItems recursive hierarchy + normalizedLinks: {}, // stores a flatten normalized object for direct id access }); -const createNavLinkItem = (link: LinkItem, linkProps?: UserPermissions): NavLinkItem => ({ - id: link.id, - path: link.path, - title: link.title, - ...(link.description != null ? { description: link.description } : {}), - ...(link.landingIcon != null ? { icon: link.landingIcon } : {}), - ...(link.landingImage != null ? { image: link.landingImage } : {}), - ...(link.links && link.links.length - ? { - links: reduceLinks({ - links: link.links, - linkProps, - formatFunction: createNavLinkItem, - }), - } - : {}), - ...(link.skipUrlState != null ? { skipUrlState: link.skipUrlState } : {}), -}); +const getAppLinksValue = (): AppLinkItems => appLinksUpdater$.getValue().links; +const getNormalizedLinksValue = (): NormalizedLinks => appLinksUpdater$.getValue().normalizedLinks; -const hasFeaturesCapability = ( - features: Feature[] | undefined, - capabilities: Capabilities -): boolean => { - if (!features) { - return true; - } - return features.some((featureKey) => get(capabilities, featureKey, false)); -}; +/** + * Subscribes to the updater to get the app links updates + */ +export const subscribeAppLinks = (onChange: (links: AppLinkItems) => void) => + appLinksUpdater$.subscribe(({ links }) => onChange(links)); -const isLinkAllowed = (link: LinkItem, linkProps?: UserPermissions) => - !( - linkProps != null && - // exclude link when license is basic and link is premium - ((linkProps.license && !linkProps.license.isAtLeast(link.licenseType ?? 'basic')) || - // exclude link when enableExperimental[hideWhenExperimentalKey] is enabled and link has hideWhenExperimentalKey - (link.hideWhenExperimentalKey != null && - linkProps.enableExperimental[link.hideWhenExperimentalKey]) || - // exclude link when enableExperimental[experimentalKey] is disabled and link has experimentalKey - (link.experimentalKey != null && !linkProps.enableExperimental[link.experimentalKey]) || - // exclude link when link is not part of enabled feature capabilities - (linkProps.capabilities != null && - !hasFeaturesCapability(link.features, linkProps.capabilities))) - ); - -export function reduceLinks({ - links, - linkProps, - formatFunction, -}: { - links: Readonly; - linkProps?: UserPermissions; - formatFunction: (link: LinkItem, linkProps?: UserPermissions) => T; -}): T[] { - return links.reduce( - (deepLinks: T[], link: LinkItem) => - isLinkAllowed(link, linkProps) ? [...deepLinks, formatFunction(link, linkProps)] : deepLinks, - [] - ); -} - -export const getInitialDeepLinks = (): AppDeepLink[] => { - return appLinks.map((link) => createDeepLink(link)); -}; +/** + * Hook to get the app links updated value + */ +export const useAppLinks = (): AppLinkItems => { + const [appLinks, setAppLinks] = useState(getAppLinksValue); -export const getDeepLinks = async ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): Promise => { - const links = await getAppLinks({ enableExperimental, license, capabilities }); - return reduceLinks({ - links, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createDeepLink, - }); -}; + useEffect(() => { + const linksSubscription = subscribeAppLinks((newAppLinks) => { + setAppLinks(newAppLinks); + }); + return () => linksSubscription.unsubscribe(); + }, []); -export const getNavLinkItems = ({ - enableExperimental, - license, - capabilities, -}: UserPermissions): NavLinkItem[] => - reduceLinks({ - links: appLinks, - linkProps: { enableExperimental, license, capabilities }, - formatFunction: createNavLinkItem, - }); + return appLinks; +}; /** - * Recursive function to create the `NormalizedLinks` structure from a `LinkItem` array parameter + * Updates the app links applying the filter by permissions */ -const getNormalizedLinks = ( - currentLinks: Readonly, - parentId?: SecurityPageName -): NormalizedLinks => { - const result = currentLinks.reduce>( - (normalized, { links, ...currentLink }) => { - normalized[currentLink.id] = { - ...currentLink, - parentId, - }; - if (links && links.length > 0) { - Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); - } - return normalized; - }, - {} - ); - return result as NormalizedLinks; +export const updateAppLinks = ( + appLinksToUpdate: AppLinkItems, + linksPermissions: LinksPermissions +) => { + const filteredAppLinks = getFilteredAppLinks(appLinksToUpdate, linksPermissions); + appLinksUpdater$.next({ + links: Object.freeze(filteredAppLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(filteredAppLinks)), + }); }; /** - * Normalized indexed version of the global `links` array, referencing the parent by id, instead of having nested links children - */ -const normalizedLinks: Readonly = Object.freeze(getNormalizedLinks(appLinks)); -/** - * Returns the `NormalizedLink` from a link id parameter. - * The object reference is frozen to make sure it is not mutated by the caller. + * Excludes a link by id from the current app links + * @deprecated this function will not be needed when async link filtering is migrated to the main getAppLinks functions */ -const getNormalizedLink = (id: SecurityPageName): Readonly => - Object.freeze(normalizedLinks[id]); +export const excludeAppLink = (linkId: SecurityPageName) => { + const { links, normalizedLinks } = appLinksUpdater$.getValue(); + if (!normalizedLinks[linkId]) { + return; + } + + let found = false; + const excludeRec = (currentLinks: AppLinkItems): LinkItem[] => + currentLinks.reduce((acc, link) => { + if (!found) { + if (link.id === linkId) { + found = true; + return acc; + } + if (link.links) { + const excludedLinks = excludeRec(link.links); + if (excludedLinks.length > 0) { + acc.push({ ...link, links: excludedLinks }); + return acc; + } + } + } + acc.push(link); + return acc; + }, []); + + const excludedLinks = excludeRec(links); + + appLinksUpdater$.next({ + links: Object.freeze(excludedLinks), + normalizedLinks: Object.freeze(getNormalizedLinks(excludedLinks)), + }); +}; /** * Returns the `LinkInfo` from a link id parameter */ -export const getLinkInfo = (id: SecurityPageName): LinkInfo => { +export const getLinkInfo = (id: SecurityPageName): LinkInfo | undefined => { + const normalizedLink = getNormalizedLink(id); + if (!normalizedLink) { + return undefined; + } // discards the parentId and creates the linkInfo copy. - const { parentId, ...linkInfo } = getNormalizedLink(id); + const { parentId, ...linkInfo } = normalizedLink; return linkInfo; }; @@ -178,9 +129,14 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { const ancestors: LinkInfo[] = []; let currentId: SecurityPageName | undefined = id; while (currentId) { - const { parentId, ...linkInfo } = getNormalizedLink(currentId); - ancestors.push(linkInfo); - currentId = parentId; + const normalizedLink = getNormalizedLink(currentId); + if (normalizedLink) { + const { parentId, ...linkInfo } = normalizedLink; + ancestors.push(linkInfo); + currentId = parentId; + } else { + currentId = undefined; + } } return ancestors.reverse(); }; @@ -190,9 +146,82 @@ export const getAncestorLinksInfo = (id: SecurityPageName): LinkInfo[] => { * Defaults to `true` if the `skipUrlState` property of the `LinkItem` is `undefined`. */ export const needsUrlState = (id: SecurityPageName): boolean => { - return !getNormalizedLink(id).skipUrlState; + return !getNormalizedLink(id)?.skipUrlState; +}; + +// Internal functions + +/** + * Creates the `NormalizedLinks` structure from a `LinkItem` array + */ +const getNormalizedLinks = ( + currentLinks: AppLinkItems, + parentId?: SecurityPageName +): NormalizedLinks => { + return currentLinks.reduce((normalized, { links, ...currentLink }) => { + normalized[currentLink.id] = { + ...currentLink, + parentId, + }; + if (links && links.length > 0) { + Object.assign(normalized, getNormalizedLinks(links, currentLink.id)); + } + return normalized; + }, {}); +}; + +const getNormalizedLink = (id: SecurityPageName): Readonly | undefined => + getNormalizedLinksValue()[id]; + +const getFilteredAppLinks = ( + appLinkToFilter: AppLinkItems, + linksPermissions: LinksPermissions +): LinkItem[] => + appLinkToFilter.reduce((acc, { links, ...appLink }) => { + if (!isLinkAllowed(appLink, linksPermissions)) { + return acc; + } + if (links) { + const childrenLinks = getFilteredAppLinks(links, linksPermissions); + if (childrenLinks.length > 0) { + acc.push({ ...appLink, links: childrenLinks }); + } else { + acc.push(appLink); + } + } else { + acc.push(appLink); + } + return acc; + }, []); + +// It checks if the user has at least one of the link capabilities needed +const hasCapabilities = (linkCapabilities: string[], userCapabilities: Capabilities): boolean => + linkCapabilities.some((linkCapability) => get(userCapabilities, linkCapability, false)); + +const isLinkAllowed = ( + link: LinkItem, + { license, experimentalFeatures, capabilities }: LinksPermissions +) => { + const linkLicenseType = link.licenseType ?? 'basic'; + if (license) { + if (!license.hasAtLeast(linkLicenseType)) { + return false; + } + } else if (linkLicenseType !== 'basic') { + return false; + } + if (link.hideWhenExperimentalKey && experimentalFeatures[link.hideWhenExperimentalKey]) { + return false; + } + if (link.experimentalKey && !experimentalFeatures[link.experimentalKey]) { + return false; + } + if (link.capabilities && !hasCapabilities(link.capabilities, capabilities)) { + return false; + } + return true; }; export const getLinksWithHiddenTimeline = (): LinkInfo[] => { - return Object.values(normalizedLinks).filter((link) => link.hideTimeline); + return Object.values(getNormalizedLinksValue()).filter((link) => link.hideTimeline); }; diff --git a/x-pack/plugins/security_solution/public/common/links/types.ts b/x-pack/plugins/security_solution/public/common/links/types.ts index bfa87851306ff..323873cafc23c 100644 --- a/x-pack/plugins/security_solution/public/common/links/types.ts +++ b/x-pack/plugins/security_solution/public/common/links/types.ts @@ -6,43 +6,73 @@ */ import { Capabilities } from '@kbn/core/types'; -import { LicenseType } from '@kbn/licensing-plugin/common/types'; +import { ILicense, LicenseType } from '@kbn/licensing-plugin/common/types'; import { IconType } from '@elastic/eui'; -import { LicenseService } from '../../../common/license'; import { ExperimentalFeatures } from '../../../common/experimental_features'; -import { CASES_FEATURE_ID, SecurityPageName, SERVER_APP_ID } from '../../../common/constants'; +import { SecurityPageName } from '../../../common/constants'; -export const FEATURE = { - general: `${SERVER_APP_ID}.show`, - casesRead: `${CASES_FEATURE_ID}.read_cases`, - casesCrud: `${CASES_FEATURE_ID}.crud_cases`, -}; - -export type Feature = Readonly; +/** + * Permissions related parameters needed for the links to be filtered + */ +export interface LinksPermissions { + capabilities: Capabilities; + experimentalFeatures: Readonly; + license?: ILicense; +} -export interface UserPermissions { - enableExperimental: ExperimentalFeatures; - license?: LicenseService; - capabilities?: Capabilities; +export interface LinkCategory { + label: string; + linkIds: readonly SecurityPageName[]; } +export type LinkCategories = Readonly; + export interface LinkItem { + /** + * The description of the link content + */ description?: string; - disabled?: boolean; // default false /** - * Displays deep link when feature flag is enabled. + * Experimental flag needed to enable the link */ experimentalKey?: keyof ExperimentalFeatures; - features?: Feature[]; /** - * Hides deep link when feature flag is enabled. + * Capabilities strings (using object dot notation) to enable the link. + * Uses "or" conditional, only one enabled capability is needed to activate the link + */ + capabilities?: string[]; + /** + * Categories to display in the navigation + */ + categories?: LinkCategories; + /** + * Enables link in the global navigation. Defaults to false. + */ + globalNavEnabled?: boolean; + /** + * Global navigation order number */ - globalNavEnabled?: boolean; // default false globalNavOrder?: number; - globalSearchEnabled?: boolean; + /** + * Disables link in the global search. Defaults to false. + */ + globalSearchDisabled?: boolean; + /** + * Keywords for the global search to search. + */ globalSearchKeywords?: string[]; + /** + * Experimental flag needed to disable the link. Opposite of experimentalKey + */ hideWhenExperimentalKey?: keyof ExperimentalFeatures; + /** + * Link id. Refers to a SecurityPageName + */ id: SecurityPageName; + /** + * Displays the "Beta" badge + */ + isBeta?: boolean; /** * Icon that is displayed on menu navigation landing page. * Only required for pages that are displayed inside a landing page. @@ -53,26 +83,38 @@ export interface LinkItem { * Only required for pages that are displayed inside a landing page. */ landingImage?: string; - isBeta?: boolean; + /** + * Minimum license required to enable the link + */ licenseType?: LicenseType; + /** + * Nested links + */ links?: LinkItem[]; + /** + * Link path relative to security root + */ path: string; - skipUrlState?: boolean; // defaults to false + /** + * Disables link in the side navigation. Defaults to false. + */ + sideNavDisabled?: boolean; + /** + * Disables the state query string in the URL. Defaults to false. + */ + skipUrlState?: boolean; + /** + * Disables the timeline call to action on the bottom of the page. Defaults to false. + */ hideTimeline?: boolean; // defaults to false + /** + * Title of the link + */ title: string; } -export interface NavLinkItem { - description?: string; - icon?: IconType; - id: SecurityPageName; - links?: NavLinkItem[]; - image?: string; - path: string; - title: string; - skipUrlState?: boolean; // default to false -} +export type AppLinkItems = Readonly; export type LinkInfo = Omit; export type NormalizedLink = LinkInfo & { parentId?: SecurityPageName }; -export type NormalizedLinks = Record; +export type NormalizedLinks = Partial>; diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 33a9f3a37a42f..ca9029c6c0939 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -6,7 +6,12 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; +import { coreMock } from '@kbn/core/public/mocks'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; +import { updateAppLinks } from '../../links'; +import { getAppLinks } from '../../links/app_links'; import { useShowTimeline } from './use_show_timeline'; +import { StartPlugins } from '../../../types'; const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); jest.mock('react-router-dom', () => { @@ -24,6 +29,23 @@ jest.mock('../../components/navigation/helpers', () => ({ })); describe('use show timeline', () => { + beforeAll(async () => { + // initialize all App links before running test + const appLinks = await getAppLinks(coreMock.createStart(), {} as StartPlugins); + updateAppLinks(appLinks, { + experimentalFeatures: allowedExperimentalValues, + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + actions: { show: true, crud: true }, + siem: { + show: true, + crud: true, + }, + }, + }); + }); describe('useIsGroupedNavigationEnabled false', () => { beforeAll(() => { mockedUseIsGroupedNavigationEnabled.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/detections/links.ts b/x-pack/plugins/security_solution/public/detections/links.ts index 1cfac62d80e6e..df9d32fcb57ed 100644 --- a/x-pack/plugins/security_solution/public/detections/links.ts +++ b/x-pack/plugins/security_solution/public/detections/links.ts @@ -5,21 +5,20 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; -import { ALERTS_PATH, SecurityPageName } from '../../common/constants'; +import { ALERTS_PATH, SecurityPageName, SERVER_APP_ID } from '../../common/constants'; import { ALERTS } from '../app/translations'; -import { LinkItem, FEATURE } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; export const links: LinkItem = { id: SecurityPageName.alerts, title: ALERTS, path: ALERTS_PATH, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalNavEnabled: true, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.alerts', { defaultMessage: 'Alerts', }), ], - globalSearchEnabled: true, globalNavOrder: 9001, }; diff --git a/x-pack/plugins/security_solution/public/hosts/links.ts b/x-pack/plugins/security_solution/public/hosts/links.ts index d1bc26c5fb3f2..dcdeb73ac1219 100644 --- a/x-pack/plugins/security_solution/public/hosts/links.ts +++ b/x-pack/plugins/security_solution/public/hosts/links.ts @@ -24,7 +24,6 @@ export const links: LinkItem = { defaultMessage: 'Hosts', }), ], - globalSearchEnabled: true, globalNavOrder: 9002, links: [ { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index 81b72527500ad..57aee98af4e9d 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksIcons } from './landing_links_icons'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', icon: 'myTestIcon', - path: '', }; const mockNavigateTo = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx index 04a3e20b1f178..b30d4f404b163 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.tsx @@ -12,7 +12,7 @@ import { SecuritySolutionLinkAnchor, withSecuritySolutionLink, } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx index c44374852f29b..81881a3796f0b 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.test.tsx @@ -8,7 +8,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { TestProviders } from '../../common/mock'; import { LandingLinksImages } from './landing_links_images'; @@ -17,7 +17,6 @@ const DEFAULT_NAV_ITEM: NavLinkItem = { title: 'TEST LABEL', description: 'TEST DESCRIPTION', image: 'TEST_IMAGE.png', - path: '', }; jest.mock('../../common/lib/kibana/kibana_react', () => { diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx index 22bcc0f1aa251..4cf8db26bbe7a 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_images.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from import React from 'react'; import styled from 'styled-components'; import { withSecuritySolutionLink } from '../../common/components/links'; -import { NavLinkItem } from '../../common/links/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; interface LandingLinksImagesProps { items: NavLinkItem[]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/constants.ts b/x-pack/plugins/security_solution/public/landing_pages/constants.ts deleted file mode 100644 index a6b72a5e7db4f..0000000000000 --- a/x-pack/plugins/security_solution/public/landing_pages/constants.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 { i18n } from '@kbn/i18n'; -import { SecurityPageName } from '../app/types'; - -export interface LandingNavGroup { - label: string; - itemIds: SecurityPageName[]; -} - -export const MANAGE_NAVIGATION_CATEGORIES: LandingNavGroup[] = [ - { - label: i18n.translate('xpack.securitySolution.landing.siemTitle', { - defaultMessage: 'SIEM', - }), - itemIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.landing.endpointsTitle', { - defaultMessage: 'ENDPOINTS', - }), - itemIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -]; diff --git a/x-pack/plugins/security_solution/public/landing_pages/links.ts b/x-pack/plugins/security_solution/public/landing_pages/links.ts new file mode 100644 index 0000000000000..48cd31485ea7f --- /dev/null +++ b/x-pack/plugins/security_solution/public/landing_pages/links.ts @@ -0,0 +1,52 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { + DASHBOARDS_PATH, + SecurityPageName, + SERVER_APP_ID, + THREAT_HUNTING_PATH, +} from '../../common/constants'; +import { DASHBOARDS, THREAT_HUNTING } from '../app/translations'; +import { LinkItem } from '../common/links/types'; +import { overviewLinks, detectionResponseLinks } from '../overview/links'; +import { links as hostsLinks } from '../hosts/links'; +import { links as networkLinks } from '../network/links'; +import { links as usersLinks } from '../users/links'; + +export const dashboardsLandingLinks: LinkItem = { + id: SecurityPageName.dashboardsLanding, + title: DASHBOARDS, + path: DASHBOARDS_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.dashboards', { + defaultMessage: 'Dashboards', + }), + ], + links: [overviewLinks, detectionResponseLinks], + skipUrlState: true, + hideTimeline: true, +}; + +export const threatHuntingLandingLinks: LinkItem = { + id: SecurityPageName.threatHuntingLanding, + title: THREAT_HUNTING, + path: THREAT_HUNTING_PATH, + globalNavEnabled: false, + capabilities: [`${SERVER_APP_ID}.show`], + globalSearchKeywords: [ + i18n.translate('xpack.securitySolution.appLinks.threatHunting', { + defaultMessage: 'Threat hunting', + }), + ], + links: [hostsLinks, networkLinks, usersLinks], + skipUrlState: true, + hideTimeline: true, +}; diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx index 1955d56c0a151..a09db6ebf5eaa 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.test.tsx @@ -9,53 +9,58 @@ import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; -import { LandingCategories } from './manage'; -import { NavLinkItem } from '../../common/links/types'; +import { ManagementCategories } from './manage'; +import { NavLinkItem } from '../../common/components/navigation/types'; const RULES_ITEM_LABEL = 'elastic rules!'; const EXCEPTIONS_ITEM_LABEL = 'exceptional!'; +const CATEGORY_1_LABEL = 'first tests category'; +const CATEGORY_2_LABEL = 'second tests category'; -const mockAppManageLink: NavLinkItem = { +const defaultAppManageLink: NavLinkItem = { id: SecurityPageName.administration, - path: '', title: 'admin', + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules], + }, + { + label: CATEGORY_2_LABEL, + linkIds: [SecurityPageName.exceptions], + }, + ], links: [ { id: SecurityPageName.rules, title: RULES_ITEM_LABEL, description: '', icon: 'testIcon1', - path: '', }, { id: SecurityPageName.exceptions, title: EXCEPTIONS_ITEM_LABEL, description: '', icon: 'testIcon2', - path: '', }, ], }; + +const mockedUseCanSeeHostIsolationExceptionsMenu = jest.fn(); +jest.mock('../../management/pages/host_isolation_exceptions/view/hooks', () => ({ + useCanSeeHostIsolationExceptionsMenu: () => mockedUseCanSeeHostIsolationExceptionsMenu(), +})); + +const mockAppManageLink = jest.fn(() => defaultAppManageLink); jest.mock('../../common/components/navigation/nav_links', () => ({ - useAppRootNavLink: jest.fn(() => mockAppManageLink), + useAppRootNavLink: () => mockAppManageLink(), })); -describe('LandingCategories', () => { - it('renders items', () => { +describe('ManagementCategories', () => { + it('should render items', () => { const { queryByText } = render( - + ); @@ -63,17 +68,19 @@ describe('LandingCategories', () => { expect(queryByText(EXCEPTIONS_ITEM_LABEL)).toBeInTheDocument(); }); - it('renders items in the same order as defined', () => { + it('should render items in the same order as defined', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: '', + linkIds: [SecurityPageName.exceptions, SecurityPageName.rules], + }, + ], + }); const { queryAllByTestId } = render( - + ); @@ -82,4 +89,109 @@ describe('LandingCategories', () => { expect(renderedItems[0]).toHaveTextContent(EXCEPTIONS_ITEM_LABEL); expect(renderedItems[1]).toHaveTextContent(RULES_ITEM_LABEL); }); + + it('should not render category items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + ], + links: [ + { + id: SecurityPageName.rules, + title: RULES_ITEM_LABEL, + description: '', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(RULES_ITEM_LABEL); + }); + + it('should not render category if all items filtered', () => { + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + links: [], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + expect(queryByText(CATEGORY_2_LABEL)).not.toBeInTheDocument(); + }); + + it('should not render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is false', () => { + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(false); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: 'test hostIsolationExceptions title', + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryByText } = render( + + + + ); + + expect(queryByText(CATEGORY_1_LABEL)).not.toBeInTheDocument(); + }); + + it('should render hostIsolationExceptionsLink when useCanSeeHostIsolationExceptionsMenu is true', () => { + const HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL = 'test hostIsolationExceptions title'; + mockedUseCanSeeHostIsolationExceptionsMenu.mockReturnValueOnce(true); + mockAppManageLink.mockReturnValueOnce({ + ...defaultAppManageLink, + categories: [ + { + label: CATEGORY_1_LABEL, + linkIds: [SecurityPageName.hostIsolationExceptions], + }, + ], + links: [ + { + id: SecurityPageName.hostIsolationExceptions, + title: HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL, + description: 'test hostIsolationExceptions description', + icon: 'testIcon1', + }, + ], + }); + const { queryAllByTestId } = render( + + + + ); + + const renderedItems = queryAllByTestId('LandingItem'); + + expect(renderedItems).toHaveLength(1); + expect(renderedItems[0]).toHaveTextContent(HOST_ISOLATION_EXCEPTIONS_ITEM_LABEL); + }); }); diff --git a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx index f0e6094d5113f..d484e5fe90a52 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/pages/manage.tsx @@ -11,18 +11,18 @@ import styled from 'styled-components'; import { SecurityPageName } from '../../app/types'; import { HeaderPage } from '../../common/components/header_page'; import { useAppRootNavLink } from '../../common/components/navigation/nav_links'; -import { NavigationCategories } from '../../common/components/navigation/types'; +import { NavLinkItem } from '../../common/components/navigation/types'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { navigationCategories } from '../../management/links'; +import { useCanSeeHostIsolationExceptionsMenu } from '../../management/pages/host_isolation_exceptions/view/hooks'; import { LandingLinksIcons } from '../components/landing_links_icons'; import { MANAGE_PAGE_TITLE } from './translations'; export const ManageLandingPage = () => ( - - + + ); @@ -31,37 +31,52 @@ const StyledEuiHorizontalRule = styled(EuiHorizontalRule)` margin-bottom: ${({ theme }) => theme.eui.paddingSizes.l}; `; -const useGetManageNavLinks = () => { - const manageNavLinks = useAppRootNavLink(SecurityPageName.administration)?.links ?? []; +type ManagementCategories = Array<{ label: string; links: NavLinkItem[] }>; +const useManagementCategories = (): ManagementCategories => { + const hideHostIsolationExceptions = !useCanSeeHostIsolationExceptionsMenu(); + const { links = [], categories = [] } = useAppRootNavLink(SecurityPageName.administration) ?? {}; - const manageLinksById = Object.fromEntries(manageNavLinks.map((link) => [link.id, link])); - return (linkIds: readonly SecurityPageName[]) => linkIds.map((linkId) => manageLinksById[linkId]); + const manageLinksById = Object.fromEntries(links.map((link) => [link.id, link])); + + return categories.reduce((acc, { label, linkIds }) => { + const linksItem = linkIds.reduce((linksAcc, linkId) => { + if ( + manageLinksById[linkId] && + !(linkId === SecurityPageName.hostIsolationExceptions && hideHostIsolationExceptions) + ) { + linksAcc.push(manageLinksById[linkId]); + } + return linksAcc; + }, []); + if (linksItem.length > 0) { + acc.push({ label, links: linksItem }); + } + return acc; + }, []); }; -export const LandingCategories = React.memo( - ({ categories }: { categories: NavigationCategories }) => { - const getManageNavLinks = useGetManageNavLinks(); +export const ManagementCategories = () => { + const managementCategories = useManagementCategories(); - return ( - <> - {categories.map(({ label, linkIds }, index) => ( -
- {index > 0 && ( - <> - - - - )} - -

{label}

-
- - -
- ))} - - ); - } -); + return ( + <> + {managementCategories.map(({ label, links }, index) => ( +
+ {index > 0 && ( + <> + + + + )} + +

{label}

+
+ + +
+ ))} + + ); +}; -LandingCategories.displayName = 'LandingCategories'; +ManagementCategories.displayName = 'ManagementCategories'; diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index ee60274cbb83d..9316f92a0d0b8 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { CoreStart } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { BLOCKLIST_PATH, @@ -17,6 +18,7 @@ import { RULES_CREATE_PATH, RULES_PATH, SecurityPageName, + SERVER_APP_ID, TRUSTED_APPS_PATH, } from '../../common/constants'; import { @@ -31,8 +33,8 @@ import { RULES, TRUSTED_APPLICATIONS, } from '../app/translations'; -import { NavigationCategories } from '../common/components/navigation/types'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { LinkItem } from '../common/links/types'; +import { StartPlugins } from '../types'; import { IconBlocklist } from './icons/blocklist'; import { IconEndpoints } from './icons/endpoints'; @@ -43,19 +45,42 @@ import { IconHostIsolation } from './icons/host_isolation'; import { IconSiemRules } from './icons/siem_rules'; import { IconTrustedApplications } from './icons/trusted_applications'; -export const links: LinkItem = { +const categories = [ + { + label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { + defaultMessage: 'SIEM', + }), + linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], + }, + { + label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { + defaultMessage: 'ENDPOINTS', + }), + linkIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.hostIsolationExceptions, + SecurityPageName.blocklist, + ], + }, +]; + +const links: LinkItem = { id: SecurityPageName.administration, title: MANAGE, path: MANAGE_PATH, skipUrlState: true, hideTimeline: true, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.manage', { defaultMessage: 'Manage', }), ], + categories, links: [ { id: SecurityPageName.rules, @@ -73,7 +98,6 @@ export const links: LinkItem = { defaultMessage: 'Rules', }), ], - globalSearchEnabled: true, links: [ { id: SecurityPageName.rulesCreate, @@ -99,7 +123,6 @@ export const links: LinkItem = { defaultMessage: 'Exception lists', }), ], - globalSearchEnabled: true, }, { id: SecurityPageName.endpoints, @@ -178,24 +201,7 @@ export const links: LinkItem = { ], }; -export const navigationCategories: NavigationCategories = [ - { - label: i18n.translate('xpack.securitySolution.appLinks.category.siem', { - defaultMessage: 'SIEM', - }), - linkIds: [SecurityPageName.rules, SecurityPageName.exceptions], - }, - { - label: i18n.translate('xpack.securitySolution.appLinks.category.endpoints', { - defaultMessage: 'ENDPOINTS', - }), - linkIds: [ - SecurityPageName.endpoints, - SecurityPageName.policies, - SecurityPageName.trustedApps, - SecurityPageName.eventFilters, - SecurityPageName.blocklist, - SecurityPageName.hostIsolationExceptions, - ], - }, -] as const; +export const getManagementLinkItems = async (core: CoreStart, plugins: StartPlugins) => { + // TODO: implement async logic to exclude links + return links; +}; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 9fd06b523347f..dbcc04b5c6d8e 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -7,14 +7,14 @@ import { i18n } from '@kbn/i18n'; import { - DASHBOARDS_PATH, DETECTION_RESPONSE_PATH, LANDING_PATH, OVERVIEW_PATH, SecurityPageName, + SERVER_APP_ID, } from '../../common/constants'; -import { DASHBOARDS, DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; -import { FEATURE, LinkItem } from '../common/links/types'; +import { DETECTION_RESPONSE, GETTING_STARTED, OVERVIEW } from '../app/translations'; +import { LinkItem } from '../common/links/types'; import overviewPageImg from '../common/images/overview_page.png'; import detectionResponsePageImg from '../common/images/detection_response_page.png'; @@ -27,7 +27,7 @@ export const overviewLinks: LinkItem = { }), path: OVERVIEW_PATH, globalNavEnabled: true, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.overview', { defaultMessage: 'Overview', @@ -41,7 +41,7 @@ export const gettingStartedLinks: LinkItem = { title: GETTING_STARTED, path: LANDING_PATH, globalNavEnabled: false, - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.getStarted', { defaultMessage: 'Getting started', @@ -62,26 +62,10 @@ export const detectionResponseLinks: LinkItem = { path: DETECTION_RESPONSE_PATH, globalNavEnabled: false, experimentalKey: 'detectionResponseEnabled', - features: [FEATURE.general], + capabilities: [`${SERVER_APP_ID}.show`], globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.detectionAndResponse', { defaultMessage: 'Detection & Response', }), ], }; - -export const dashboardsLandingLinks: LinkItem = { - id: SecurityPageName.dashboardsLanding, - title: DASHBOARDS, - path: DASHBOARDS_PATH, - globalNavEnabled: false, - features: [FEATURE.general], - globalSearchKeywords: [ - i18n.translate('xpack.securitySolution.appLinks.dashboards', { - defaultMessage: 'Dashboards', - }), - ], - links: [overviewLinks, detectionResponseLinks], - skipUrlState: true, - hideTimeline: true, -}; diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4b49c04f295a5..1716e08febd40 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -45,9 +45,11 @@ import { DETECTION_ENGINE_INDEX_URL, SERVER_APP_ID, SOURCERER_API_URL, + ENABLE_GROUPED_NAVIGATION, } from '../common/constants'; -import { getDeepLinks } from './app/deep_links'; +import { getDeepLinks, registerDeepLinksUpdater } from './app/deep_links'; +import { AppLinkItems, subscribeAppLinks, updateAppLinks } from './common/links'; import { getSubPluginRoutesByCapabilities, manageOldSiemRoutes } from './helpers'; import { SecurityAppStore } from './common/store/store'; import { licenseService } from './common/hooks/use_license'; @@ -140,7 +142,6 @@ export class Plugin implements IPlugin { // required to show the alert table inside cases const { alertsTableConfigurationRegistry } = plugins.triggersActionsUi; @@ -171,7 +172,15 @@ export class Plugin implements IPlugin { const [coreStart] = await core.getStartServices(); - manageOldSiemRoutes(coreStart); + + const subscription = subscribeAppLinks((links: AppLinkItems) => { + // It has to be called once after deep links are initialized + if (links.length > 0) { + manageOldSiemRoutes(coreStart); + subscription.unsubscribe(); + } + }); + return () => true; }, }); @@ -220,35 +229,65 @@ export class Plugin implements IPlugin { - if (currentLicense.type !== undefined) { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - currentLicense.type, - core.application.capabilities - ), - })); + + if (newNavEnabled) { + registerDeepLinksUpdater(this.appUpdater$); + } + + // Not using await to prevent blocking start execution + this.lazyApplicationLinks().then(({ getAppLinks }) => { + getAppLinks(core, plugins).then((appLinks) => { + if (licensing !== null) { + this.licensingSubscription = licensing.subscribe((currentLicense) => { + if (currentLicense.type !== undefined) { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + license: currentLicense, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), + })); + } + } + }); + } else { + updateAppLinks(appLinks, { + experimentalFeatures: this.experimentalFeatures, + capabilities: core.application.capabilities, + }); + + if (!newNavEnabled) { + // TODO: remove block when nav flag no longer needed + this.appUpdater$.next(() => ({ + navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed + deepLinks: getDeepLinks( + this.experimentalFeatures, + undefined, + core.application.capabilities + ), + })); + } } }); - } else { - this.appUpdater$.next(() => ({ - navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks( - this.experimentalFeatures, - undefined, - core.application.capabilities - ), - })); - } + }); return {}; } @@ -296,11 +335,22 @@ export class Plugin implements IPlugin