[Asset Inventory] Onboarding Permission Denied component (#216113)

## Summary

This PR adds the Permission Denied screen to the Asset Inventory
onboarding to handle users with insufficient privileges attempting to
enable the entity store. It currently relies on the same mechanisms and
reuses the same callout message used by the Entity Store page.

this pr also updates the following:
- update texts on the get started and initializing screens to match the
latest updates
- update the MissingPrivilegesCallout component to allow displaying full
message instead of line clamping
- adds MissingPrivilegesCallout unit tests


## Screenshots

<img width="1551" alt="image"
src="https://github.com/user-attachments/assets/4bde6f6f-8feb-49da-a8e0-c68e324cb782"
/>
This commit is contained in:
Paulo Silva 2025-04-01 13:14:45 -07:00 committed by GitHub
parent a382856f71
commit a467201723
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 192 additions and 57 deletions

View file

@ -13,12 +13,12 @@ export type AssetInventoryStatus =
| 'disabled'
| 'initializing'
| 'empty'
| 'permission_denied'
| 'insufficient_privileges'
| 'ready';
export interface AssetInventoryStatusResponse {
status: AssetInventoryStatus;
privileges?: EntityAnalyticsPrivileges['privileges'];
privileges?: EntityAnalyticsPrivileges;
}
export type AssetInventoryEnableResponse = InitEntityStoreResponse;

View file

@ -12,6 +12,7 @@ import { AssetInventoryLoading } from '../asset_inventory_loading';
import { useAssetInventoryStatus } from '../../hooks/use_asset_inventory_status';
import { Initializing } from './initializing';
import { NoDataFound } from './no_data_found';
import { PermissionDenied } from './permission_denied';
/**
* This component serves as a wrapper to render appropriate onboarding screens
@ -35,13 +36,8 @@ export const AssetInventoryOnboarding: FC<PropsWithChildren> = ({ children }) =>
return <Initializing />;
case 'empty': // Onboarding cannot proceed because no relevant data was found.
return <NoDataFound />;
case 'permission_denied': // Todo: User lacks the necessary permissions to proceed.
return (
<div>
{'Permission denied.'}
<pre>{JSON.stringify(privileges)}</pre>
</div>
);
case 'insufficient_privileges': // User lacks the necessary permissions to proceed.
return <PermissionDenied privileges={privileges} />;
default:
// If no onboarding status matches, render the child components.
return children;

View file

@ -30,11 +30,7 @@ describe('GetStarted Component', () => {
expect(screen.getByText(/get started with asset inventory/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /enable asset inventory/i })).toBeInTheDocument();
expect(screen.getByText(/read documentation/i).closest('a')).toHaveAttribute(
'href',
'https://ela.st/asset-inventory'
);
expect(screen.getByText(/need help?/i)).toBeInTheDocument();
});
it('calls enableAssetInventory when enable asset inventory button is clicked', async () => {

View file

@ -10,8 +10,6 @@ import {
EuiImage,
EuiEmptyPrompt,
EuiButton,
EuiLink,
EuiTitle,
EuiFlexGroup,
EuiFlexItem,
EuiCallOut,
@ -25,8 +23,7 @@ import { HoverForExplanationTooltip } from './hover_for_explanation_tooltip';
import { EmptyStateIllustrationContainer } from '../empty_state_illustration_container';
import { useEnableAssetInventory } from './hooks/use_enable_asset_inventory';
import { TEST_SUBJ_ONBOARDING_GET_STARTED } from '../../constants';
const ASSET_INVENTORY_DOCS_URL = 'https://ela.st/asset-inventory';
import { NeedHelp } from './need_help';
export const GetStarted = () => {
const { isEnabling, error, reset, enableAssetInventory } = useEnableAssetInventory();
@ -67,7 +64,7 @@ export const GetStarted = () => {
<p>
<FormattedMessage
id="xpack.securitySolution.assetInventory.onboarding.getStarted.description"
defaultMessage="Asset Inventory gives you a unified view of all assets detected by Elastic Security, including those observed in logs, events, or discovered through integrations with sources like {identity_providers}, {cloud_services}, {mdms}, and configuration management {databases}."
defaultMessage="Asset Inventory provides a unified view of all your organizations assets in one place. See everything detected by Elastic Security — whether from logs, {identity_providers}, {cloud_services}, {mdms} or configuration {databases} — all in a structured, searchable inventory. Enable Asset Inventory to gain complete visibility across your environment."
values={{
identity_providers: (
<HoverForExplanationTooltip
@ -167,24 +164,7 @@ export const GetStarted = () => {
)}
</EuiButton>,
]}
footer={
<>
<EuiTitle size="xxs">
<strong>
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.needHelp"
defaultMessage="Need help?"
/>
</strong>
</EuiTitle>{' '}
<EuiLink href={ASSET_INVENTORY_DOCS_URL} target="_blank">
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.readDocumentation"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
}
footer={<NeedHelp />}
/>
</CenteredWrapper>
</EuiFlexItem>

View file

@ -26,7 +26,7 @@ describe('Initializing', () => {
it('should navigate to the integrations page when clicking "Add integration" button', async () => {
renderWithTestProvider(<Initializing />);
expect(screen.getByText(/initializing asset inventory/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /discovering your assets/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /add integration/i }));

View file

@ -40,7 +40,7 @@ export const Initializing = () => {
<h2>
<FormattedMessage
id="xpack.securitySolution.assetInventory.onboarding.initializing.title"
defaultMessage="Initializing Asset Inventory"
defaultMessage="Discovering Your Assets"
/>
</h2>
}
@ -48,7 +48,7 @@ export const Initializing = () => {
body={
<FormattedMessage
id="xpack.securitySolution.assetInventory.onboarding.initializing.description"
defaultMessage="Your Asset Inventory is being set up. This may take a few moments as we prepare to provide you with centralized visibility into your assets. Check back shortly to start exploring your assets."
defaultMessage="We're currently analyzing your connected data sources to build a comprehensive inventory of your assets. This typically takes just a few minutes to complete. You'll be automatically redirected when your inventory is ready to explore."
/>
}
footer={
@ -63,7 +63,7 @@ export const Initializing = () => {
<strong>
<FormattedMessage
id="xpack.securitySolution.assetInventory.initializing.exploreTitle"
defaultMessage="Explore Asset Integrations"
defaultMessage="Explore Asset Discovery Integrations"
/>
</strong>
</EuiTitle>
@ -72,7 +72,7 @@ export const Initializing = () => {
<EuiText size="s">
<FormattedMessage
id="xpack.securitySolution.assetInventory.initializing.exploreDescription"
defaultMessage="Explore the out-of-the-box integrations we provide to connect your data sources."
defaultMessage="Discover assets across cloud, identity, and other environments for deeper visibility."
/>
</EuiText>
</EuiFlexItem>

View file

@ -0,0 +1,23 @@
/*
* 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 { screen } from '@testing-library/react';
import { NeedHelp } from './need_help';
import { renderWithTestProvider } from '../../test/test_provider';
describe('NeedHelp Component', () => {
it('renders the component with correct title and link', () => {
renderWithTestProvider(<NeedHelp />);
expect(screen.getByText(/need help?/i)).toBeInTheDocument();
expect(screen.getByText(/read documentation/i).closest('a')).toHaveAttribute(
'href',
'https://ela.st/asset-inventory'
);
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 { EuiLink, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DOCS_URL } from '../../constants';
export const NeedHelp = () => {
return (
<>
<EuiTitle size="xxs">
<strong>
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.needHelp"
defaultMessage="Need help?"
/>
</strong>
</EuiTitle>{' '}
<EuiLink href={DOCS_URL} target="_blank">
<FormattedMessage
id="xpack.securitySolution.assetInventory.emptyState.readDocumentation"
defaultMessage="Read documentation"
/>
</EuiLink>
</>
);
};

View file

@ -8,18 +8,13 @@ import React from 'react';
import { screen } from '@testing-library/react';
import { useSpaceId } from '../../../common/hooks/use_space_id';
import { NoDataFound } from './no_data_found';
import { TEST_SUBJ_ONBOARDING_SUCCESS_CALLOUT } from '../../constants';
import { renderWithTestProvider } from '../../test/test_provider';
// Mocking components which implementation details are out of scope for this unit test
jest.mock('../../../onboarding/components/onboarding_context', () => ({
OnboardingContextProvider: () => <div data-test-subj="onboarding-grid" />,
}));
jest.mock('./onboarding_success_callout', () => ({
OnboardingSuccessCallout: () => (
<div data-test-subj="asset-inventory-onboarding-success-callout" />
),
}));
jest.mock('../../../common/hooks/use_space_id');
describe('NoDataFound Component', () => {
@ -41,9 +36,9 @@ describe('NoDataFound Component', () => {
it('should render the No Data Found related content when spaceId is available', () => {
renderWithTestProvider(<NoDataFound />);
expect(screen.getByText(/start onboarding your assets/i)).toBeInTheDocument();
expect(screen.getByTestId(TEST_SUBJ_ONBOARDING_SUCCESS_CALLOUT)).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: /connect sources to discover assets/i })
).toBeInTheDocument();
expect(screen.getByTestId('onboarding-grid')).toBeInTheDocument();
});

View file

@ -22,7 +22,6 @@ import { AssetInventoryTitle } from '../asset_inventory_title';
import { AssetInventoryLoading } from '../asset_inventory_loading';
import illustration from '../../../common/images/integrations_light.png';
import { IntegrationsCardGridTabs } from '../../../onboarding/components/onboarding_body/cards/integrations/integration_card_grid_tabs';
import { OnboardingSuccessCallout } from './onboarding_success_callout';
import { TEST_SUBJ_ONBOARDING_NO_DATA_FOUND } from '../../constants';
export const NoDataFound = () => {
@ -36,7 +35,6 @@ export const NoDataFound = () => {
<>
<AssetInventoryTitle />
<EuiSpacer size="l" />
<OnboardingSuccessCallout />
<EuiPanel data-test-subj={TEST_SUBJ_ONBOARDING_NO_DATA_FOUND}>
<EuiFlexGroup>
<EuiFlexItem>
@ -44,7 +42,7 @@ export const NoDataFound = () => {
<h3>
<FormattedMessage
id="xpack.securitySolution.onboarding.startOnboardingAssets"
defaultMessage="Start onboarding your assets"
defaultMessage="Connect Sources to Discover Assets"
/>
</h3>
</EuiTitle>

View file

@ -0,0 +1,40 @@
/*
* 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 { PermissionDenied } from './permission_denied';
import { renderWithTestProvider } from '../../test/test_provider';
import { screen } from '@testing-library/dom';
import { TEST_SUBJ_ONBOARDING_PERMISSION_DENIED } from '../../constants';
import type { AssetInventoryStatusResponse } from '../../../../common/api/asset_inventory/types';
describe('PermissionDenied Component', () => {
it('should render the component correctly', () => {
renderWithTestProvider(<PermissionDenied />);
expect(screen.getByTestId(TEST_SUBJ_ONBOARDING_PERMISSION_DENIED)).toBeInTheDocument();
expect(screen.getByText(/permission denied/i)).toBeInTheDocument();
expect(screen.getByText(/need help?/i)).toBeInTheDocument();
});
it('should render the insufficient permissions callout when the privileges are passed', () => {
const privileges: AssetInventoryStatusResponse['privileges'] = {
has_all_required: false,
has_read_permissions: true,
has_write_permissions: false,
privileges: {
elasticsearch: {
index: { 'logs-*': { read: false, view_index_metadata: false } },
},
},
};
renderWithTestProvider(<PermissionDenied privileges={privileges} />);
expect(screen.getByText(/missing elasticsearch index privileges/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { EuiImage, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { AssetInventoryStatusResponse } from '../../../../common/api/asset_inventory/types';
import { MissingPrivilegesCallout } from '../../../entity_analytics/components/entity_store/components/missing_privileges_callout';
import illustration from '../../../common/images/lock_light.png';
import { CenteredWrapper } from './centered_wrapper';
import { EmptyStateIllustrationContainer } from '../empty_state_illustration_container';
import { TEST_SUBJ_ONBOARDING_PERMISSION_DENIED } from '../../constants';
import { NeedHelp } from './need_help';
import { AssetInventoryTitle } from '../asset_inventory_title';
interface PermissionDeniedProps {
privileges?: AssetInventoryStatusResponse['privileges'];
}
export const PermissionDenied = ({ privileges }: PermissionDeniedProps) => {
return (
<EuiFlexGroup>
<EuiFlexItem>
<AssetInventoryTitle />
<CenteredWrapper>
<EuiEmptyPrompt
data-test-subj={TEST_SUBJ_ONBOARDING_PERMISSION_DENIED}
icon={
<EmptyStateIllustrationContainer>
<EuiImage
url={illustration}
size="fullWidth"
alt={i18n.translate(
'xpack.securitySolution.assetInventory.permissionDenied.illustrationAlt',
{
defaultMessage: 'Permission Denied',
}
)}
/>
</EmptyStateIllustrationContainer>
}
title={
<h2>
<FormattedMessage
id="xpack.securitySolution.assetInventory.onboarding.permissionDenied.title"
defaultMessage="Permission Denied"
/>
</h2>
}
layout="horizontal"
color="plain"
body={
<>
<p>
<FormattedMessage
id="xpack.securitySolution.assetInventory.onboarding.permissionDenied.description"
defaultMessage="You do not have the necessary permissions to enable or view the Asset Inventory. To access this feature, please contact your administrator to request the appropriate permissions."
/>
</p>
{privileges ? <MissingPrivilegesCallout privileges={privileges} /> : null}
</>
}
footer={<NeedHelp />}
/>
</CenteredWrapper>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -31,6 +31,8 @@ export const TEST_SUBJ_ONBOARDING_GET_STARTED = 'asset-inventory-onboarding-get-
export const TEST_SUBJ_ONBOARDING_INITIALIZING = 'asset-inventory-onboarding-initializing';
export const TEST_SUBJ_ONBOARDING_NO_DATA_FOUND = 'asset-inventory-onboarding-no-data-found';
export const TEST_SUBJ_ONBOARDING_SUCCESS_CALLOUT = 'asset-inventory-onboarding-success-callout';
export const TEST_SUBJ_ONBOARDING_PERMISSION_DENIED =
'asset-inventory-onboarding-permission-denied';
export const TEST_SUBJ_GROUPING = 'asset-inventory-grouping';
export const TEST_SUBJ_GROUPING_LOADING = 'asset-inventory-grouping-loading';
export const TEST_SUBJ_GROUPING_COUNTER = 'asset-inventory-grouping-counter';

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View file

@ -38,7 +38,7 @@ describe('AssetInventoryDataClient', () => {
jest.clearAllMocks();
});
it('returns INSUFFICIENT_PRIVILEGES when user lacks required privileges', async () => {
it('returns INSUFFICIENT_PRIVILEGES with missing privileges when user lacks required privileges', async () => {
const noPrivileges = {
...mockEntityStorePrivileges,
has_all_required: false,
@ -48,7 +48,7 @@ describe('AssetInventoryDataClient', () => {
expect(result).toEqual({
status: 'insufficient_privileges',
privileges: noPrivileges.privileges,
privileges: noPrivileges,
});
});

View file

@ -111,7 +111,7 @@ export class AssetInventoryDataClient {
if (!entityStorePrivileges.has_all_required) {
return {
status: ASSET_INVENTORY_STATUS.INSUFFICIENT_PRIVILEGES,
privileges: entityStorePrivileges.privileges,
privileges: entityStorePrivileges,
};
}