Skip to content

Commit

Permalink
[Security Solution] [Onboarding] AI connectors card privileges escena…
Browse files Browse the repository at this point in the history
…rios (elastic#198014)

## Summary

Issue elastic#197397 - Fix how AI connectors cards handle the lack of
privileges for user.

**Before:**
When user has none privileges inside the AI connectors card an infinite
spinner is rendered.


![379211667-8f565693-a9a6-43dc-9a88-d6b678f5692c](https://github.com/user-attachments/assets/d2d6330d-d464-413a-bf18-3737db8d573c)

**Introduced by this PR:** 
### **Case 1: User has all privileges**
<img width="613" alt="Screenshot 2024-10-28 at 15 52 53"
src="https://github.com/user-attachments/assets/bb5f7dd6-e025-4ba1-aeaa-4f2dbe1d5d77">

User capabilities: `actions.show`, `actions.save` & `actions.execute`

**Case 1.1** - User does not have any connector
<img width="3232" alt="Untitled (1)"
src="https://github.com/user-attachments/assets/b1cfa090-5ff0-4b7b-a6f0-19f05438ad7a">

**Case 1.2** - User already has connectors
<img width="3231" alt="Untitled"
src="https://github.com/user-attachments/assets/aed0ea61-a043-475f-8cd4-8f9189a43d59">

### **Case 2: User can read only**
User capabilities: `actions.show` & `actions.execute`
<img width="614" alt="Screenshot 2024-10-28 at 15 51 39"
src="https://github.com/user-attachments/assets/2f4c31b1-157e-4927-bb16-c3d400ca5f5d">

**Case 2.1** - User does not have any connector
<img width="3231" alt="Untitled (3)"
src="https://github.com/user-attachments/assets/f64687f3-c74b-4d27-a978-7bea4448289d">

**Case 2.2** - User already has connectors
<img width="3231" alt="Untitled (2)"
src="https://github.com/user-attachments/assets/cc4df86e-f7dd-4957-883a-94e2135b9d19">

### **Case 3: User can not read or save**
<img width="610" alt="Screenshot 2024-10-28 at 15 52 16"
src="https://github.com/user-attachments/assets/534cb671-3629-4413-9cbc-e33babf83b08">

<img width="3231" alt="Untitled (4)"
src="https://github.com/user-attachments/assets/aafe7693-da40-47eb-847f-61045da15490">

### Checklist

Delete any items that are not applicable to this PR.

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Elastic Machine <[email protected]>
Co-authored-by: Angela Chuang <[email protected]>
  • Loading branch information
3 people authored Oct 31, 2024
1 parent 5499b69 commit 99b9b5e
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 80 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiText } from '@elastic/eui';
import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import { OnboardingCardId } from '../../../../constants';
import type { OnboardingCardComponent } from '../../../../types';
Expand All @@ -15,6 +15,7 @@ import { OnboardingCardContentPanel } from '../common/card_content_panel';
import { ConnectorCards } from './connectors/connector_cards';
import { CardCallOut } from '../common/card_callout';
import type { AssistantCardMetadata } from './types';
import { MissingPrivilegesDescription } from './connectors/missing_privileges_tooltip';

export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
isCardComplete,
Expand All @@ -32,43 +33,55 @@ export const AssistantCard: OnboardingCardComponent<AssistantCardMetadata> = ({
}, [setExpandedCardId]);

const connectors = checkCompleteMetadata?.connectors;
const canExecuteConnectors = checkCompleteMetadata?.canExecuteConnectors;
const canCreateConnectors = checkCompleteMetadata?.canCreateConnectors;

return (
<OnboardingCardContentPanel style={{ paddingTop: 0 }}>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.ASSISTANT_CARD_DESCRIPTION}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
{isIntegrationsCardComplete ? (
<ConnectorCards connectors={connectors} onConnectorSaved={checkComplete} />
) : (
<EuiFlexItem
className={css`
width: 45%;
`}
>
<CardCallOut
color="primary"
icon="iInCircle"
text={i18n.ASSISTANT_CARD_CALLOUT_INTEGRATIONS_TEXT}
action={
<EuiLink onClick={expandIntegrationsCard}>
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem>{i18n.ASSISTANT_CARD_CALLOUT_INTEGRATIONS_BUTTON}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="arrowRight" color="primary" size="s" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
}
{canExecuteConnectors ? (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{i18n.ASSISTANT_CARD_DESCRIPTION}
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
{isIntegrationsCardComplete ? (
<ConnectorCards
canCreateConnectors={canCreateConnectors}
connectors={connectors}
onConnectorSaved={checkComplete}
/>
</EuiFlexItem>
)}
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexItem
className={css`
width: 45%;
`}
>
<CardCallOut
color="primary"
icon="iInCircle"
text={i18n.ASSISTANT_CARD_CALLOUT_INTEGRATIONS_TEXT}
action={
<EuiLink onClick={expandIntegrationsCard}>
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem>{i18n.ASSISTANT_CARD_CALLOUT_INTEGRATIONS_BUTTON}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIcon type="arrowRight" color="primary" size="s" />
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
}
/>
</EuiFlexItem>
)}
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiCallOut title={i18n.PRIVILEGES_MISSING_TITLE} iconType="iInCircle">
<MissingPrivilegesDescription />
</EuiCallOut>
)}
</OnboardingCardContentPanel>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import type { AssistantCardMetadata } from './types';

export const checkAssistantCardComplete: OnboardingCardCheckComplete<
AssistantCardMetadata
> = async ({ http }) => {
> = async ({ http, application }) => {
const allConnectors = await loadConnectors({ http });
const {
capabilities: { actions },
} = application;

const aiConnectors = allConnectors.reduce((acc: AIConnector[], connector) => {
if (!connector.isMissingSecrets && AllowedActionTypeIds.includes(connector.actionTypeId)) {
Expand All @@ -37,6 +40,8 @@ export const checkAssistantCardComplete: OnboardingCardCheckComplete<
completeBadgeText,
metadata: {
connectors: aiConnectors,
canExecuteConnectors: Boolean(actions?.show && actions?.execute),
canCreateConnectors: Boolean(actions?.save),
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,69 +15,104 @@ import {
EuiText,
EuiBadge,
EuiSpacer,
EuiCallOut,
} from '@elastic/eui';
import { css } from '@emotion/css';
import { useKibana } from '../../../../../../common/lib/kibana';
import { CreateConnectorPopover } from './create_connector_popover';
import { ConnectorSetup } from './connector_setup';
import * as i18n from './translations';
import { MissingPrivilegesDescription } from './missing_privileges_tooltip';

interface ConnectorCardsProps {
connectors?: AIConnector[];
onConnectorSaved: () => void;
canCreateConnectors?: boolean;
}

export const ConnectorCards = React.memo<ConnectorCardsProps>(
({ connectors, onConnectorSaved }) => {
({ connectors, onConnectorSaved, canCreateConnectors }) => {
const {
triggersActionsUi: { actionTypeRegistry },
} = useKibana().services;

if (!connectors) return <EuiLoadingSpinner />;
if (!connectors) {
return <EuiLoadingSpinner />;
}

const hasConnectors = connectors.length > 0;

if (connectors.length > 0) {
// show callout when user is missing actions.save privilege
if (!hasConnectors && !canCreateConnectors) {
return (
<>
<EuiFlexGroup
wrap
className={css`
max-height: 290px;
overflow-y: auto;
`}
>
{connectors.map((connector) => (
<EuiFlexItem
key={connector.id}
grow={false}
className={css`
width: 30%;
`}
>
<EuiPanel hasShadow={false} hasBorder paddingSize="m">
<EuiFlexGroup direction="row" gutterSize="s" wrap>
<EuiFlexItem
className={css`
min-width: 100%;
`}
>
<EuiText size="s">{connector.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
{actionTypeRegistry.get(connector.actionTypeId).actionTypeTitle}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
<EuiSpacer />
<CreateConnectorPopover onConnectorSaved={onConnectorSaved} />
</>
<EuiCallOut title={i18n.PRIVILEGES_MISSING_TITLE} iconType="iInCircle">
<MissingPrivilegesDescription />
</EuiCallOut>
);
}

return <ConnectorSetup onConnectorSaved={onConnectorSaved} />;
return (
<>
{hasConnectors ? (
<>
<ConnectorList connectors={connectors} actionTypeRegistry={actionTypeRegistry} />
<EuiSpacer />
<CreateConnectorPopover
canCreateConnectors={canCreateConnectors}
onConnectorSaved={onConnectorSaved}
/>
</>
) : (
<ConnectorSetup onConnectorSaved={onConnectorSaved} />
)}
</>
);
}
);
ConnectorCards.displayName = 'ConnectorCards';

interface ConnectorListProps {
connectors: AIConnector[];
actionTypeRegistry: ReturnType<
typeof useKibana
>['services']['triggersActionsUi']['actionTypeRegistry'];
}

const ConnectorList = React.memo<ConnectorListProps>(({ connectors, actionTypeRegistry }) => (
<EuiFlexGroup
wrap
className={css`
max-height: 290px;
overflow-y: auto;
`}
>
{connectors.map((connector) => (
<EuiFlexItem
key={connector.id}
grow={false}
className={css`
width: 30%;
`}
>
<EuiPanel hasShadow={false} hasBorder paddingSize="m">
<EuiFlexGroup direction="row" gutterSize="s" wrap>
<EuiFlexItem
className={css`
min-width: 100%;
`}
>
<EuiText size="s">{connector.name}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBadge color="hollow">
{actionTypeRegistry.get(connector.actionTypeId).actionTypeTitle}
</EuiBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGroup>
));

ConnectorList.displayName = 'ConnectorList';
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,32 @@ import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { EuiPopover, EuiLink, EuiText } from '@elastic/eui';
import { ConnectorSetup } from './connector_setup';
import * as i18n from '../translations';
import * as i18n from './translations';
import { MissingPrivilegesTooltip } from './missing_privileges_tooltip';

interface CreateConnectorPopoverProps {
onConnectorSaved: () => void;
canCreateConnectors?: boolean;
}

export const CreateConnectorPopover = React.memo<CreateConnectorPopoverProps>(
({ onConnectorSaved }) => {
({ onConnectorSaved, canCreateConnectors }) => {
const [isOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);

const onButtonClick = useCallback(
() => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen),
[]
);
if (!canCreateConnectors) {
return (
<MissingPrivilegesTooltip>
<EuiLink data-test-subj="createConnectorPopoverButton" onClick={onButtonClick} disabled>
{i18n.ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER}
</EuiLink>
</MissingPrivilegesTooltip>
);
}

return (
<EuiPopover
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* 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 { EuiCode, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui';
import * as i18n from './translations';

interface MissingPrivilegesTooltip {
children: React.ReactElement; // EuiToolTip requires a single ReactElement child
}

export const MissingPrivilegesTooltip = React.memo<MissingPrivilegesTooltip>(({ children }) => (
<EuiToolTip
anchorProps={{ style: { width: 'fit-content' } }}
title={i18n.PRIVILEGES_MISSING_TITLE}
content={<MissingPrivilegesDescription />}
>
{children}
</EuiToolTip>
));
MissingPrivilegesTooltip.displayName = 'MissingPrivilegesTooltip';

export const MissingPrivilegesDescription = React.memo(() => {
return (
<EuiFlexGroup gutterSize="m" direction="column" data-test-subj="missingPrivilegesGroup">
<EuiFlexItem>{i18n.PRIVILEGES_REQUIRED_TITLE}</EuiFlexItem>
<EuiFlexItem>
<EuiCode>
<ul>
<li>{i18n.REQUIRED_PRIVILEGES_CONNECTORS_ALL}</li>
</ul>
</EuiCode>
</EuiFlexItem>
<EuiFlexItem>{i18n.CONTACT_ADMINISTRATOR}</EuiFlexItem>
</EuiFlexGroup>
);
});
MissingPrivilegesDescription.displayName = 'MissingPrivilegesDescription';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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';

export const ASSISTANT_CARD_CREATE_NEW_CONNECTOR_POPOVER = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.createNewConnectorPopover',
{
defaultMessage: 'Create new connector',
}
);

export const PRIVILEGES_MISSING_TITLE = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.missingPrivileges.title',
{
defaultMessage: 'Missing privileges',
}
);

export const PRIVILEGES_REQUIRED_TITLE = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.requiredPrivileges',
{
defaultMessage: 'The minimum Kibana privileges required to use this feature are:',
}
);

export const REQUIRED_PRIVILEGES_CONNECTORS_ALL = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.requiredPrivileges.connectorsAll',
{
defaultMessage: 'Management > Connectors: All',
}
);

export const CONTACT_ADMINISTRATOR = i18n.translate(
'xpack.securitySolution.onboarding.assistantCard.missingPrivileges.contactAdministrator',
{
defaultMessage: 'Contact your administrator for assistance.',
}
);
Loading

0 comments on commit 99b9b5e

Please sign in to comment.