[Fleet] Add callout for granular privileges callout (#184026)

This commit is contained in:
Nicolas Chaulet 2024-05-22 15:49:09 -04:00 committed by GitHub
parent 4785ae6b12
commit 42afaa38a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 159 additions and 87 deletions

View file

@ -858,6 +858,7 @@ export const getDocLinks = ({ kibanaBranch, buildFlavor }: GetDocLinkOptions): D
remoteESOoutput: `${FLEET_DOCS}monitor-elastic-agent.html#external-elasticsearch-monitoring`,
performancePresets: `${FLEET_DOCS}es-output-settings.html#es-output-settings-performance-tuning-settings`,
scalingKubernetesResourcesAndLimits: `${FLEET_DOCS}scaling-on-kubernetes.html#_specifying_resources_and_limits_in_agent_manifests`,
roleAndPrivileges: `${FLEET_DOCS}fleet-roles-and-privileges.html`,
},
ecs: {
guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/${ECS_VERSION}/index.html`,

View file

@ -551,6 +551,7 @@ export interface DocLinks {
remoteESOoutput: string;
performancePresets: string;
scalingKubernetesResourcesAndLimits: string;
roleAndPrivileges: string;
}>;
readonly ecs: {
readonly guide: string;

View file

@ -7,9 +7,11 @@
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiCallOut, EuiLink } from '@elastic/eui';
import { useDismissableTour } from '../../../../hooks/use_dismissable_tour';
import type { Section } from '../../sections';
import { useLink, useConfig, useAuthz } from '../../hooks';
import { useLink, useConfig, useAuthz, useStartServices } from '../../hooks';
import { WithHeaderLayout } from '../../../../layouts';
import { ExperimentalFeaturesService } from '../../services';
@ -30,7 +32,10 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
const { getHref } = useLink();
const { agents } = useConfig();
const authz = useAuthz();
const { agentTamperProtectionEnabled } = ExperimentalFeaturesService.get();
const { agentTamperProtectionEnabled, subfeaturePrivileges } = ExperimentalFeaturesService.get();
const { docLinks } = useStartServices();
const granularPrivilegesCallout = useDismissableTour('GRANULAR_PRIVILEGES');
const tabs = [
{
@ -109,8 +114,37 @@ export const DefaultLayout: React.FunctionComponent<Props> = ({
.map(({ isHidden, ...tab }) => tab);
return (
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
{children}
</WithHeaderLayout>
<>
{!subfeaturePrivileges || !authz.fleet.all || granularPrivilegesCallout.isHidden ? null : (
<EuiCallOut
size="s"
iconType="cheer"
onDismiss={granularPrivilegesCallout.dismiss}
title={
<>
<FormattedMessage
id="xpack.fleet.granularPrivileges.callOutContent"
defaultMessage="We've added new privileges that let you define more granularly who can view or edit Fleet agents, policies, and settings. {learnMoreLink}"
values={{
learnMoreLink: (
<EuiLink href={docLinks.links.fleet.roleAndPrivileges} external>
<strong>
<FormattedMessage
id="xpack.fleet.granularPrivileges.learnMoreLinkText"
defaultMessage="Learn more."
/>
</strong>
</EuiLink>
),
}}
/>
</>
}
/>
)}
<WithHeaderLayout leftColumn={<DefaultPageTitle />} rightColumn={rightColumn} tabs={tabs}>
{children}
</WithHeaderLayout>
</>
);
};

View file

@ -13,7 +13,7 @@ import { AgentStatusFilter } from './agent_status_filter';
const PARTIAL_TOUR_TEXT = 'Some agents have become inactive and have been hidden';
const mockStorage: Record<any, any> = {};
jest.mock('../../../../../../hooks', () => {
jest.mock('../../../../../../hooks/use_core', () => {
return {
useStartServices: jest.fn(() => ({
uiSettings: {
@ -81,14 +81,13 @@ describe('AgentStatusFilter', () => {
it('Should not show tour if previously been dismissed', async () => {
mockStorage['fleet.inactiveAgentsTour'] = { active: false };
const { getByText } = renderComponent({
const { queryByText } = renderComponent({
selectedStatus: [],
onSelectedStatusChange: () => {},
totalInactiveAgents: 999,
});
await act(async () => {
expect(getByText(PARTIAL_TOUR_TEXT, { exact: false })).not.toBeVisible();
expect(queryByText(PARTIAL_TOUR_TEXT, { exact: false })).toBeNull();
});
});

View file

@ -16,12 +16,15 @@ import {
EuiTourStep,
EuiLink,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { useInactiveAgentsCalloutHasBeenDismissed, useLastSeenInactiveAgentsCount } from '../hooks';
import { useDismissableTour } from '../../../../../../hooks/use_dismissable_tour';
import { useLastSeenInactiveAgentsCount } from '../hooks';
const statusFilters = [
{
@ -122,8 +125,8 @@ export const AgentStatusFilter: React.FC<{
} = props;
const [lastSeenInactiveAgentsCount, setLastSeenInactiveAgentsCount] =
useLastSeenInactiveAgentsCount();
const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] =
useInactiveAgentsCalloutHasBeenDismissed();
const { isHidden: inactiveAgentsCalloutHasBeenDismissed, dismiss: dismissInactiveAgentsCallout } =
useDismissableTour('INACTIVE_AGENTS');
const newlyInactiveAgentsCount = useMemo(() => {
const newVal = totalInactiveAgents - lastSeenInactiveAgentsCount;
@ -159,7 +162,7 @@ export const AgentStatusFilter: React.FC<{
const updateIsStatusFilterOpen = (isOpen: boolean) => {
if (isOpen && newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed) {
setInactiveAgentsCalloutHasBeenDismissed(true);
dismissInactiveAgentsCallout();
}
setIsStatusFilterOpen(isOpen);
@ -206,7 +209,7 @@ export const AgentStatusFilter: React.FC<{
return (
<InactiveAgentsTourStep
isOpen={newlyInactiveAgentsCount > 0 && !inactiveAgentsCalloutHasBeenDismissed}
setInactiveAgentsCalloutHasBeenDismissed={setInactiveAgentsCalloutHasBeenDismissed}
setInactiveAgentsCalloutHasBeenDismissed={dismissInactiveAgentsCallout}
>
<EuiPopover
ownFocus

View file

@ -8,7 +8,6 @@
export { useUpdateTags } from './use_update_tags';
export { useActionStatus } from './use_action_status';
export { useLastSeenInactiveAgentsCount } from './use_last_seen_inactive_agents_count';
export { useInactiveAgentsCalloutHasBeenDismissed } from './use_inactive_agents_callout_has_been_dismissed';
export { useAgentSoftLimit } from './use_agent_soft_limit';
export { useMissingEncryptionKeyCallout } from './use_missing_encryption_key_callout';
export { useFetchAgentsData } from './use_fetch_agents_data';

View file

@ -1,39 +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 { useState, useEffect } from 'react';
import type { TOUR_STORAGE_CONFIG } from '../../../../../../constants';
import { TOUR_STORAGE_KEYS } from '../../../../../../constants';
import { useStartServices } from '../../../../../../hooks';
export const useInactiveAgentsCalloutHasBeenDismissed = (): [boolean, (val: boolean) => void] => {
const { uiSettings, storage } = useStartServices();
const [inactiveAgentsCalloutHasBeenDismissed, setInactiveAgentsCalloutHasBeenDismissed] =
useState(false);
useEffect(() => {
setInactiveAgentsCalloutHasBeenDismissed(
uiSettings.get('hideAnnouncements', false) ||
(
storage.get(TOUR_STORAGE_KEYS.INACTIVE_AGENTS) as
| TOUR_STORAGE_CONFIG['INACTIVE_AGENTS']
| undefined
)?.active === false
);
}, [storage, uiSettings]);
const updateInactiveAgentsCalloutHasBeenDismissed = (newValue: boolean) => {
storage.set(TOUR_STORAGE_KEYS.INACTIVE_AGENTS, {
active: false,
} as TOUR_STORAGE_CONFIG['INACTIVE_AGENTS']);
setInactiveAgentsCalloutHasBeenDismissed(newValue);
};
return [inactiveAgentsCalloutHasBeenDismissed, updateInactiveAgentsCalloutHasBeenDismissed];
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
@ -15,9 +15,8 @@ import { useTheme } from 'styled-components';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import type { TOUR_STORAGE_CONFIG } from '../constants';
import { TOUR_STORAGE_KEYS } from '../constants';
import { useStartServices } from '../hooks';
import { useDismissableTour } from '../hooks/use_dismissable_tour';
export const AddAgentHelpPopover = ({
button,
@ -30,31 +29,16 @@ export const AddAgentHelpPopover = ({
offset?: number;
closePopover: NoArgCallback<void>;
}) => {
const { docLinks, uiSettings, storage } = useStartServices();
const { docLinks } = useStartServices();
const theme = useTheme() as EuiTheme;
const optionalProps: { offset?: number } = {};
const hideAddAgentTour: boolean = useMemo(() => {
return (
uiSettings.get('hideAnnouncements', false) ||
(
storage.get(TOUR_STORAGE_KEYS.ADD_AGENT_POPOVER) as
| TOUR_STORAGE_CONFIG['ADD_AGENT_POPOVER']
| undefined
)?.active === false
);
}, [storage, uiSettings]);
const onFinish = () => {
storage.set(TOUR_STORAGE_KEYS.ADD_AGENT_POPOVER, {
active: false,
} as TOUR_STORAGE_CONFIG['ADD_AGENT_POPOVER']);
};
const addAgentTour = useDismissableTour('ADD_AGENT_POPOVER');
if (offset !== undefined) {
optionalProps.offset = offset; // offset being present in props sets it to 0 so only add if specified
}
return hideAddAgentTour ? (
return addAgentTour.isHidden ? (
button
) : (
<EuiTourStep
@ -81,7 +65,7 @@ export const AddAgentHelpPopover = ({
zIndex={theme.eui.euiZLevel1 - 1} // put popover behind any modals that happen to be open
isStepOpen={isOpen}
minWidth={300}
onFinish={onFinish}
onFinish={addAgentTour.dismiss}
step={1}
stepsTotal={1}
title={
@ -96,7 +80,7 @@ export const AddAgentHelpPopover = ({
footerAction={
<EuiLink
onClick={() => {
onFinish();
addAgentTour.dismiss();
closePopover();
}}
>

View file

@ -48,16 +48,15 @@ export const TOUR_STORAGE_KEYS = {
AGENT_ACTIVITY: 'fleet.agentActivityTour',
ADD_AGENT_POPOVER: 'fleet.addAgentPopoverTour',
INACTIVE_AGENTS: 'fleet.inactiveAgentsTour',
GRANULAR_PRIVILEGES: 'fleet.granularPrivileges',
};
export interface TOUR_STORAGE_CONFIG {
AGENT_ACTIVITY: {
active: boolean;
};
ADD_AGENT_POPOVER: {
active: boolean;
};
INACTIVE_AGENTS: {
active: boolean;
};
export interface TourConfig {
active: boolean;
}
export type TourKey = keyof typeof TOUR_STORAGE_KEYS;
export type TOUR_STORAGE_CONFIG = {
[k in TourKey]: TourConfig;
};

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { TOUR_STORAGE_KEYS } from '../constants';
import { createStartServices } from '../mock';
import { useStartServices } from './use_core';
import { useDismissableTour } from './use_dismissable_tour';
jest.mock('./use_core');
describe('useDismissableTour', () => {
let startServices: ReturnType<typeof createStartServices>;
beforeEach(() => {
startServices = createStartServices('/app/fleet');
jest.mocked(useStartServices).mockReturnValue(startServices);
});
it('should display the tour by default', () => {
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
expect(res.result.current.isHidden).toBe(false);
});
it('should allow to dismiss the tour', () => {
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
expect(res.result.current.isHidden).toBe(false);
act(() => res.result.current.dismiss());
expect(res.result.current.isHidden).toBe(true);
const storageValue = startServices.storage.get(TOUR_STORAGE_KEYS.GRANULAR_PRIVILEGES);
expect(storageValue).toBeDefined();
expect(storageValue.active).toEqual(false);
});
it('should not display the tour if hideAnnouncements:true', () => {
jest.mocked(startServices.uiSettings.get).mockReturnValue(true);
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
expect(res.result.current.isHidden).toBe(true);
});
it('should not display the tour if it is already dismissed', () => {
startServices.storage.set(TOUR_STORAGE_KEYS.GRANULAR_PRIVILEGES, {
active: false,
});
const res = renderHook(() => useDismissableTour('GRANULAR_PRIVILEGES'));
expect(res.result.current.isHidden).toBe(true);
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 TourKey, type TourConfig, TOUR_STORAGE_KEYS } from '../constants';
import { useStartServices } from './use_core';
export function useDismissableTour(tourKey: TourKey) {
const { storage, uiSettings } = useStartServices();
const defaultValue =
uiSettings.get('hideAnnouncements', false) ||
(storage.get(TOUR_STORAGE_KEYS[tourKey]) as TourConfig | undefined)?.active === false;
const [isHidden, setIsHidden] = React.useState(defaultValue);
const dismiss = React.useCallback(() => {
setIsHidden(true);
storage.set(TOUR_STORAGE_KEYS[tourKey], {
active: false,
});
}, [tourKey, storage]);
return {
isHidden,
dismiss,
};
}