Skip to content

Commit 4f578b3

Browse files
authored
Merge branch 'security/feature/alert-user-assignment' into security/feature/alert-user-assignment-7820
2 parents 8288c39 + 912af23 commit 4f578b3

20 files changed

Lines changed: 1096 additions & 268 deletions

File tree

x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alert_assignees_actions.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ export interface UseAlertAssigneesActionsProps {
1818
closePopover: () => void;
1919
ecsRowData: Ecs;
2020
refetch?: () => void;
21+
refresh?: () => void;
2122
}
2223

2324
export const useAlertAssigneesActions = ({
2425
closePopover,
2526
ecsRowData,
2627
refetch,
28+
refresh,
2729
}: UseAlertAssigneesActionsProps) => {
2830
const { hasIndexWrite } = useAlertsPrivileges();
2931
const alertId = ecsRowData._id;
@@ -68,10 +70,11 @@ export const useAlertAssigneesActions = ({
6870
closePopoverMenu: closePopover,
6971
setIsBulkActionsLoading: () => {},
7072
alertItems: alertAssigneeData,
73+
refresh,
7174
});
7275
return { title: panel.title, content, id: panel.id };
7376
}),
74-
[alertAssigneeData, alertAssigneesPanels, closePopover]
77+
[alertAssigneeData, alertAssigneesPanels, closePopover, refresh]
7578
);
7679

7780
return {

x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ import { getUserPrivilegesMockDefaultValue } from '../../../common/components/us
3737
import { allCasesPermissions } from '../../../cases_test_utils';
3838
import { HostStatus } from '../../../../common/endpoint/types';
3939
import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants';
40-
import { ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE } from '../../../common/components/toolbar/bulk_actions/translations';
40+
import {
41+
ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
42+
ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
43+
} from '../../../common/components/toolbar/bulk_actions/translations';
4144

4245
jest.mock('../../../common/components/user_privileges');
4346

@@ -249,6 +252,13 @@ describe('take action dropdown', () => {
249252
).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE);
250253
});
251254
});
255+
test('should render "Apply alert assignees"', async () => {
256+
await waitFor(() => {
257+
expect(
258+
wrapper.find('[data-test-subj="alert-assignees-context-menu-item"]').first().text()
259+
).toEqual(ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE);
260+
});
261+
});
252262
});
253263

254264
describe('for Endpoint related actions', () => {

x-pack/plugins/security_solution/public/detections/components/take_action_dropdown/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useKibana } from '../../../common/lib/kibana';
3535
import { getOsqueryActionItem } from '../osquery/osquery_action_item';
3636
import type { AlertTableContextMenuItem } from '../alerts_table/types';
3737
import { useAlertTagsActions } from '../alerts_table/timeline_actions/use_alert_tags_actions';
38+
import { useAlertAssigneesActions } from '../alerts_table/timeline_actions/use_alert_assignees_actions';
3839

3940
interface ActionsData {
4041
alertStatus: Status;
@@ -189,6 +190,13 @@ export const TakeActionDropdown = React.memo(
189190
refetch,
190191
});
191192

193+
const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({
194+
closePopover: closePopoverHandler,
195+
ecsRowData: ecsData ?? { _id: actionsData.eventId },
196+
refresh: refetchFlyoutData,
197+
refetch,
198+
});
199+
192200
const { investigateInTimelineActionItems } = useInvestigateInTimeline({
193201
ecsRowData: ecsData,
194202
onInvestigateInTimelineAlertClick: closePopoverHandler,
@@ -214,7 +222,12 @@ export const TakeActionDropdown = React.memo(
214222
const alertsActionItems = useMemo(
215223
() =>
216224
!isEvent && actionsData.ruleId
217-
? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems]
225+
? [
226+
...statusActionItems,
227+
...alertTagsItems,
228+
...alertAssigneesItems,
229+
...exceptionActionItems,
230+
]
218231
: isEndpointEvent && canCreateEndpointEventFilters
219232
? eventFilterActionItems
220233
: [],
@@ -227,6 +240,7 @@ export const TakeActionDropdown = React.memo(
227240
isEvent,
228241
actionsData.ruleId,
229242
alertTagsItems,
243+
alertAssigneesItems,
230244
]
231245
);
232246

@@ -271,6 +285,7 @@ export const TakeActionDropdown = React.memo(
271285
items,
272286
},
273287
...alertTagsPanels,
288+
...alertAssigneesPanels,
274289
];
275290

276291
const takeActionButton = useMemo(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import React from 'react';
9+
import { render } from '@testing-library/react';
10+
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
11+
12+
import {
13+
ASSIGNEES_ADD_BUTTON_TEST_ID,
14+
ASSIGNEES_COUNT_BADGE_TEST_ID,
15+
ASSIGNEES_TITLE_TEST_ID,
16+
ASSIGNEES_VALUE_TEST_ID,
17+
ASSIGNEE_AVATAR_TEST_ID,
18+
} from './test_ids';
19+
import { Assignees } from './assignees';
20+
21+
import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles';
22+
import { useSuggestUsers } from '../../../../detections/containers/detection_engine/alerts/use_suggest_users';
23+
import type { SetAlertAssigneesFunc } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
24+
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
25+
import { TestProviders } from '../../../../common/mock';
26+
27+
jest.mock('../../../../detections/containers/detection_engine/alerts/use_get_user_profiles');
28+
jest.mock('../../../../detections/containers/detection_engine/alerts/use_suggest_users');
29+
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees');
30+
31+
const mockUserProfiles: UserProfileWithAvatar[] = [
32+
{ uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} },
33+
{ uid: 'user-id-2', enabled: true, user: { username: 'user2', full_name: 'User 2' }, data: {} },
34+
{ uid: 'user-id-3', enabled: true, user: { username: 'user3', full_name: 'User 3' }, data: {} },
35+
];
36+
37+
const renderAssignees = (
38+
eventId = 'event-1',
39+
alertAssignees = ['user-id-1'],
40+
onAssigneesUpdated = jest.fn()
41+
) =>
42+
render(
43+
<TestProviders>
44+
<Assignees
45+
eventId={eventId}
46+
alertAssignees={alertAssignees}
47+
onAssigneesUpdated={onAssigneesUpdated}
48+
/>
49+
</TestProviders>
50+
);
51+
52+
describe('<Assignees />', () => {
53+
let setAlertAssigneesMock: jest.Mocked<SetAlertAssigneesFunc>;
54+
55+
beforeEach(() => {
56+
jest.clearAllMocks();
57+
(useGetUserProfiles as jest.Mock).mockReturnValue({
58+
loading: false,
59+
userProfiles: mockUserProfiles,
60+
});
61+
(useSuggestUsers as jest.Mock).mockReturnValue({
62+
loading: false,
63+
userProfiles: mockUserProfiles,
64+
});
65+
66+
setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve());
67+
(useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock);
68+
});
69+
70+
it('should render component', () => {
71+
const { getByTestId } = renderAssignees();
72+
73+
expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument();
74+
expect(getByTestId(ASSIGNEES_VALUE_TEST_ID)).toBeInTheDocument();
75+
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
76+
});
77+
78+
it('should render assignees avatars', () => {
79+
const assignees = ['user-id-1', 'user-id-2'];
80+
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
81+
82+
expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).toBeInTheDocument();
83+
expect(getByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).toBeInTheDocument();
84+
85+
expect(queryByTestId(ASSIGNEES_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument();
86+
});
87+
88+
it('should render badge with assignees count in case there are more than two users assigned to an alert', () => {
89+
const assignees = ['user-id-1', 'user-id-2', 'user-id-3'];
90+
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
91+
92+
const assigneesCountBadge = getByTestId(ASSIGNEES_COUNT_BADGE_TEST_ID);
93+
expect(assigneesCountBadge).toBeInTheDocument();
94+
expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`);
95+
96+
expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user1'))).not.toBeInTheDocument();
97+
expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user2'))).not.toBeInTheDocument();
98+
expect(queryByTestId(ASSIGNEE_AVATAR_TEST_ID('user3'))).not.toBeInTheDocument();
99+
});
100+
101+
it('should call assignees update functionality with the right arguments', () => {
102+
const assignees = ['user-id-1', 'user-id-2'];
103+
const { getByTestId, getByText } = renderAssignees('test-event', assignees);
104+
105+
// Update assignees
106+
getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click();
107+
getByText('User 1').click();
108+
getByText('User 3').click();
109+
110+
// Close assignees popover
111+
getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click();
112+
113+
expect(setAlertAssigneesMock).toHaveBeenCalledWith(
114+
{
115+
assignees_to_add: ['user-id-3'],
116+
assignees_to_remove: ['user-id-1'],
117+
},
118+
['test-event'],
119+
expect.anything(),
120+
expect.anything()
121+
);
122+
});
123+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { FC } from 'react';
9+
import React, { memo, useCallback, useEffect, useState } from 'react';
10+
import {
11+
EuiFlexGroup,
12+
EuiFlexItem,
13+
EuiNotificationBadge,
14+
EuiTitle,
15+
EuiToolTip,
16+
} from '@elastic/eui';
17+
import { FormattedMessage } from '@kbn/i18n-react';
18+
import { UserAvatar } from '@kbn/user-profile-components';
19+
import { noop } from 'lodash';
20+
import { useGetUserProfiles } from '../../../../detections/containers/detection_engine/alerts/use_get_user_profiles';
21+
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
22+
import {
23+
ASSIGNEE_AVATAR_TEST_ID,
24+
ASSIGNEES_TITLE_TEST_ID,
25+
ASSIGNEES_VALUE_TEST_ID,
26+
ASSIGNEES_COUNT_BADGE_TEST_ID,
27+
} from './test_ids';
28+
import { AssigneesPopover } from './assignees_popover';
29+
30+
export interface AssigneesProps {
31+
/**
32+
* Id of the document
33+
*/
34+
eventId: string;
35+
36+
/**
37+
* The array of ids of the users assigned to the alert
38+
*/
39+
alertAssignees: string[];
40+
41+
/**
42+
* Callback to handle the successful assignees update
43+
*/
44+
onAssigneesUpdated?: () => void;
45+
}
46+
47+
/**
48+
* Document assignees details displayed in flyout right section header
49+
*/
50+
export const Assignees: FC<AssigneesProps> = memo(
51+
({ eventId, alertAssignees, onAssigneesUpdated }) => {
52+
const { userProfiles } = useGetUserProfiles(alertAssignees);
53+
const setAlertAssignees = useSetAlertAssignees();
54+
55+
const assignees = userProfiles?.filter((user) => alertAssignees.includes(user.uid)) ?? [];
56+
57+
const [selectedAssignees, setSelectedAssignees] = useState<string[] | undefined>();
58+
const [needToUpdateAssignees, setNeedToUpdateAssignees] = useState(false);
59+
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
60+
61+
const onSuccess = useCallback(() => {
62+
if (onAssigneesUpdated) onAssigneesUpdated();
63+
}, [onAssigneesUpdated]);
64+
65+
const handleOnAlertAssigneesSubmit = useCallback(async () => {
66+
if (setAlertAssignees && selectedAssignees) {
67+
const existingIds = alertAssignees;
68+
const updatedIds = selectedAssignees;
69+
70+
const assigneesToAddArray = updatedIds.filter((uid) => !existingIds.includes(uid));
71+
const assigneesToRemoveArray = existingIds.filter((uid) => !updatedIds.includes(uid));
72+
73+
const assigneesToUpdate = {
74+
assignees_to_add: assigneesToAddArray,
75+
assignees_to_remove: assigneesToRemoveArray,
76+
};
77+
78+
await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop);
79+
}
80+
}, [alertAssignees, eventId, onSuccess, selectedAssignees, setAlertAssignees]);
81+
82+
const togglePopover = useCallback(() => {
83+
setIsPopoverOpen((value) => !value);
84+
setNeedToUpdateAssignees(true);
85+
}, []);
86+
87+
const onClosePopover = useCallback(() => {
88+
// Order matters here because needToUpdateAssignees will likely be true already
89+
// from the togglePopover call when opening the popover, so if we set the popover to false
90+
// first, we'll get a rerender and then get another after we set needToUpdateAssignees to true again
91+
setNeedToUpdateAssignees(true);
92+
setIsPopoverOpen(false);
93+
}, []);
94+
95+
const onUsersChange = useCallback((users: string[]) => {
96+
setSelectedAssignees(users);
97+
}, []);
98+
99+
useEffect(() => {
100+
// selectedAssignees will be undefined on initial render or a rerender occurs, so we only want to update the assignees
101+
// after the users have been changed in some manner not when it is an initial value
102+
if (isPopoverOpen === false && needToUpdateAssignees && selectedAssignees) {
103+
setNeedToUpdateAssignees(false);
104+
handleOnAlertAssigneesSubmit();
105+
}
106+
}, [handleOnAlertAssigneesSubmit, isPopoverOpen, needToUpdateAssignees, selectedAssignees]);
107+
108+
return (
109+
<EuiFlexGroup alignItems="center" direction="row" gutterSize="xs">
110+
<EuiFlexItem grow={false}>
111+
<EuiTitle size="xxs" data-test-subj={ASSIGNEES_TITLE_TEST_ID}>
112+
<h3>
113+
<FormattedMessage
114+
id="xpack.securitySolution.flyout.right.header.assignedTitle"
115+
defaultMessage="Assigned:"
116+
/>
117+
</h3>
118+
</EuiTitle>
119+
</EuiFlexItem>
120+
<EuiFlexItem grow={false}>
121+
<span data-test-subj={ASSIGNEES_VALUE_TEST_ID}>
122+
{assignees.length > 2 ? (
123+
<EuiToolTip
124+
position="top"
125+
content={assignees.map((user) => (
126+
<div>{user.user.email ?? user.user.username}</div>
127+
))}
128+
repositionOnScroll={true}
129+
>
130+
<EuiNotificationBadge data-test-subj={ASSIGNEES_COUNT_BADGE_TEST_ID}>
131+
{assignees.length}
132+
</EuiNotificationBadge>
133+
</EuiToolTip>
134+
) : (
135+
assignees.map((user) => (
136+
<UserAvatar
137+
key={user.uid}
138+
data-test-subj={ASSIGNEE_AVATAR_TEST_ID(user.user.username)}
139+
user={user.user}
140+
avatar={user.data.avatar}
141+
size={'s'}
142+
/>
143+
))
144+
)}
145+
</span>
146+
</EuiFlexItem>
147+
<EuiFlexItem grow={false}>
148+
<AssigneesPopover
149+
existingAssigneesIds={alertAssignees}
150+
isPopoverOpen={isPopoverOpen}
151+
onUsersChange={onUsersChange}
152+
onClosePopover={onClosePopover}
153+
togglePopover={togglePopover}
154+
/>
155+
</EuiFlexItem>
156+
</EuiFlexGroup>
157+
);
158+
}
159+
);
160+
161+
Assignees.displayName = 'Assignees';

0 commit comments

Comments
 (0)