diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx index 19c303840fc1a6..078db1e6dbe6da 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx @@ -14,7 +14,7 @@ import { TestProviders } from '../../common/mock'; import { CommentRequest, CommentType, SECURITY_SOLUTION_OWNER } from '../../../common'; import { usePostComment } from '../../containers/use_post_comment'; -import { AddComment, AddCommentRefObject } from '.'; +import { AddComment, AddCommentProps, AddCommentRefObject } from '.'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; import { timelineIntegrationMock } from '../__mock__/timeline'; @@ -25,10 +25,9 @@ const onCommentSaving = jest.fn(); const onCommentPosted = jest.fn(); const postComment = jest.fn(); -const addCommentProps = { +const addCommentProps: AddCommentProps = { caseId: '1234', - disabled: false, - insertQuote: null, + userCanCrud: true, onCommentSaving, onCommentPosted, showLoading: false, @@ -94,11 +93,11 @@ describe('AddComment ', () => { ).toBeTruthy(); }); - it('should disable submit button when disabled prop passed', () => { + it('should disable submit button when isLoading is true', () => { usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); const wrapper = mount( - + ); @@ -107,12 +106,23 @@ describe('AddComment ', () => { ).toBeTruthy(); }); + it('should hide the component when the user does not have crud permissions', () => { + usePostCommentMock.mockImplementation(() => ({ ...defaultPostComment, isLoading: true })); + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy(); + }); + it('should insert a quote', async () => { const sampleQuote = 'what a cool quote'; const ref = React.createRef(); const wrapper = mount( - + ); @@ -143,7 +153,7 @@ describe('AddComment ', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx index 04104f0b9471d0..6604f3d2b8bc8f 100644 --- a/x-pack/plugins/cases/public/components/add_comment/index.tsx +++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx @@ -33,9 +33,9 @@ export interface AddCommentRefObject { addQuote: (quote: string) => void; } -interface AddCommentProps { +export interface AddCommentProps { caseId: string; - disabled?: boolean; + userCanCrud?: boolean; onCommentSaving?: () => void; onCommentPosted: (newCase: Case) => void; showLoading?: boolean; @@ -45,7 +45,7 @@ interface AddCommentProps { export const AddComment = React.memo( forwardRef( ( - { caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, + { caseId, userCanCrud, onCommentPosted, onCommentSaving, showLoading = true, subCaseId }, ref ) => { const owner = useOwnerContext(); @@ -91,31 +91,33 @@ export const AddComment = React.memo( return ( {isLoading && showLoading && } -
- - {i18n.ADD_COMMENT} - - ), - }} - /> - - + {userCanCrud && ( +
+ + {i18n.ADD_COMMENT} + + ), + }} + /> + + + )}
); } diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 7452fe7e44b3c4..73dcc18b971083 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -52,17 +52,27 @@ export const CasesTableHeader: FunctionComponent = ({ wrap={true} data-test-subj="all-cases-header" > - - - - - - + {userCanCrud ? ( + <> + + + + + + + + + ) : ( + // doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons + // to the right + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx index e29551f43c2bd5..b8755d03e0b001 100644 --- a/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/nav_buttons.tsx @@ -17,7 +17,6 @@ interface OwnProps { actionsErrors: ErrorMessage[]; configureCasesNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; - userCanCrud: boolean; } type Props = OwnProps; @@ -26,14 +25,13 @@ export const NavButtons: FunctionComponent = ({ actionsErrors, configureCasesNavigation, createCaseNavigation, - userCanCrud, }) => ( } titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''} @@ -41,7 +39,6 @@ export const NavButtons: FunctionComponent = ({ = ({ {i18n.NO_CASES}} titleSize="xs" - body={i18n.NO_CASES_BODY} + body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY} actions={ - - {i18n.ADD_NEW_CASE} - + userCanCrud && ( + + {i18n.ADD_NEW_CASE} + + ) } /> } diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 0f535b771ec8a3..8da90f32fabdf2 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -12,11 +12,19 @@ export * from '../../common/translations'; export const NO_CASES = i18n.translate('xpack.cases.caseTable.noCases.title', { defaultMessage: 'No Cases', }); + export const NO_CASES_BODY = i18n.translate('xpack.cases.caseTable.noCases.body', { defaultMessage: 'There are no cases to display. Please create a new case or change your filter settings above.', }); +export const NO_CASES_BODY_READ_ONLY = i18n.translate( + 'xpack.cases.caseTable.noCases.readonly.body', + { + defaultMessage: 'There are no cases to display. Please change your filter settings above.', + } +); + export const ADD_NEW_CASE = i18n.translate('xpack.cases.caseTable.addNewCase', { defaultMessage: 'Add New Case', }); diff --git a/x-pack/plugins/cases/public/components/callout/helpers.tsx b/x-pack/plugins/cases/public/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/cases/public/components/callout/helpers.tsx +++ b/x-pack/plugins/cases/public/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/cases/public/components/callout/translations.ts b/x-pack/plugins/cases/public/components/callout/translations.ts index dca622e60c863b..8b0ad31dba88e3 100644 --- a/x-pack/plugins/cases/public/components/callout/translations.ts +++ b/x-pack/plugins/cases/public/components/callout/translations.ts @@ -7,15 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate('xpack.cases.readOnlyFeatureTitle', { - defaultMessage: 'You cannot open new or update existing cases', -}); - -export const READ_ONLY_FEATURE_MSG = i18n.translate('xpack.cases.readOnlyFeatureDescription', { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', -}); - export const DISMISS_CALLOUT = i18n.translate('xpack.cases.dismissErrorsPushServiceCallOutTitle', { defaultMessage: 'Dismiss', }); diff --git a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx index c2578dc3debdb6..6816575d649f75 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/actions.tsx @@ -19,14 +19,12 @@ interface CaseViewActions { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; } const ActionsComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, }) => { // Delete case const { @@ -39,7 +37,6 @@ const ActionsComponent: React.FC = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'trash', label: i18n.DELETE_CASE(), onClick: handleToggleModal, @@ -54,7 +51,7 @@ const ActionsComponent: React.FC = ({ ] : []), ], - [disabled, handleToggleModal, currentExternalIncident] + [handleToggleModal, currentExternalIncident] ); if (isDeleted) { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx index 724d35b20df535..3040b0fe47a470 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.test.tsx @@ -26,6 +26,7 @@ describe('CaseActionBar', () => { onRefresh, onUpdateField, currentExternalIncident: null, + userCanCrud: true, }; beforeEach(() => { diff --git a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx index d8e012b0721065..3448d112dadd12 100644 --- a/x-pack/plugins/cases/public/components/case_action_bar/index.tsx +++ b/x-pack/plugins/cases/public/components/case_action_bar/index.tsx @@ -40,7 +40,7 @@ interface CaseActionBarProps { allCasesNavigation: CasesNavigation; caseData: Case; currentExternalIncident: CaseService | null; - disabled?: boolean; + userCanCrud: boolean; disableAlerting: boolean; isLoading: boolean; onRefresh: () => void; @@ -50,8 +50,8 @@ const CaseActionBarComponent: React.FC = ({ allCasesNavigation, caseData, currentExternalIncident, - disabled = false, disableAlerting, + userCanCrud, isLoading, onRefresh, onUpdateField, @@ -87,7 +87,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -108,7 +108,7 @@ const CaseActionBarComponent: React.FC = ({ - {!disableAlerting && ( + {userCanCrud && !disableAlerting && ( @@ -122,7 +122,7 @@ const CaseActionBarComponent: React.FC = ({ @@ -134,14 +134,15 @@ const CaseActionBarComponent: React.FC = ({ {i18n.CASE_REFRESH} - - - + {userCanCrud && ( + + + + )} diff --git a/x-pack/plugins/cases/public/components/case_view/index.tsx b/x-pack/plugins/cases/public/components/case_view/index.tsx index df57e49073a604..05f1c6727b1680 100644 --- a/x-pack/plugins/cases/public/components/case_view/index.tsx +++ b/x-pack/plugins/cases/public/components/case_view/index.tsx @@ -230,7 +230,9 @@ export const CaseComponent = React.memo( [updateCase, fetchCaseUserActions, caseId, subCaseId] ); - const { loading: isLoadingConnectors, connectors } = useConnectors(); + const { loading: isLoadingConnectors, connectors, permissionsError } = useConnectors({ + toastPermissionsErrors: false, + }); const [connectorName, isValidConnector] = useMemo(() => { const connector = connectors.find((c) => c.id === caseData.connector.id); @@ -363,7 +365,7 @@ export const CaseComponent = React.memo( allCasesNavigation={allCasesNavigation} caseData={caseData} currentExternalIncident={currentExternalIncident} - disabled={!userCanCrud} + userCanCrud={userCanCrud} disableAlerting={ruleDetailsNavigation == null} isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} @@ -406,7 +408,7 @@ export const CaseComponent = React.memo( useFetchAlertData={useFetchAlertData} userCanCrud={userCanCrud} /> - {(caseData.type !== CaseType.collection || hasDataToPush) && ( + {(caseData.type !== CaseType.collection || hasDataToPush) && userCanCrud && ( <> ( @@ -450,16 +451,15 @@ export const CaseComponent = React.memo( /> ( onSubmit={onSubmitConnector} selectedConnector={caseData.connector.id} userActions={caseUserActions} + permissionsError={permissionsError} /> diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index 1385e8e8664c37..33efb7e447583e 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { waitFor } from '@testing-library/react'; -import { EditConnector } from './index'; +import { EditConnector, EditConnectorProps } from './index'; import { getFormMock, useFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; @@ -21,9 +21,9 @@ jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; const onSubmit = jest.fn(); -const defaultProps = { +const defaultProps: EditConnectorProps = { connectors: connectorsMock, - disabled: false, + userCanCrud: true, isLoading: false, onSubmit, selectedConnector: 'none', @@ -144,4 +144,53 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="connector-loading"]`).last().exists()).toBeTruthy() ); }); + + it('does not allow the connector to be edited when the user does not have write permissions', async () => { + const props = { ...defaultProps, userCanCrud: false }; + const wrapper = mount( + + + + ); + await waitFor(() => + expect(wrapper.find(`[data-test-subj="connector-edit"]`).exists()).toBeFalsy() + ); + }); + + it('displays the permissions error message when one is provided', async () => { + const props = { ...defaultProps, permissionsError: 'error message' }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeTruthy(); + + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeFalsy(); + }); + }); + + it('displays the default none connector message', async () => { + const props = { ...defaultProps }; + const wrapper = mount( + + + + ); + + await waitFor(() => { + expect( + wrapper.find(`[data-test-subj="edit-connector-permissions-error-msg"]`).exists() + ).toBeFalsy(); + expect( + wrapper.find(`[data-test-subj="edit-connector-no-connectors-msg"]`).exists() + ).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index ad6b5a5e7cddf5..570f6e34d25287 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -30,7 +30,7 @@ import { schema } from './schema'; import { getConnectorFieldsFromUserActions } from './helpers'; import * as i18n from './translations'; -interface EditConnectorProps { +export interface EditConnectorProps { caseFields: ConnectorTypeFields['fields']; connectors: ActionConnector[]; isLoading: boolean; @@ -42,8 +42,9 @@ interface EditConnectorProps { ) => void; selectedConnector: string; userActions: CaseUserActions[]; - disabled?: boolean; + userCanCrud?: boolean; hideConnectorServiceNowSir?: boolean; + permissionsError?: string; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -104,12 +105,13 @@ export const EditConnector = React.memo( ({ caseFields, connectors, - disabled = false, + userCanCrud = true, hideConnectorServiceNowSir = false, isLoading, onSubmit, selectedConnector, userActions, + permissionsError, }: EditConnectorProps) => { const { form } = useForm({ defaultValue: { connectorId: selectedConnector }, @@ -203,6 +205,18 @@ export const EditConnector = React.memo( }); }, [dispatch]); + /** + * if this evaluates to true it means that the connector was likely deleted because the case connector was set to something + * other than none but we don't find it in the list of connectors returned from the actions plugin + */ + const connectorFromCaseMissing = currentConnector == null && selectedConnector !== 'none'; + + /** + * True if the chosen connector from the form was the "none" connector or no connector was in the case. The + * currentConnector will be null initially and after the form initializes if the case connector is "none" + */ + const connectorUndefinedOrNone = currentConnector == null || currentConnector?.id === 'none'; + return ( @@ -210,11 +224,10 @@ export const EditConnector = React.memo(

{i18n.CONNECTORS}

{isLoading && } - {!isLoading && !editConnector && ( + {!isLoading && !editConnector && userCanCrud && ( - {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. - !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. - !editConnector && ( - + {!editConnector && permissionsError ? ( + + {permissionsError} + + ) : ( + // if we're not editing the connectors and the connector specified in the case was found and the connector + // is undefined or explicitly set to none + !editConnector && + !connectorFromCaseMissing && + connectorUndefinedOrNone && ( + {i18n.NO_CONNECTOR} - )} + ) + )} ; createCaseNavigation: CasesNavigation; + hasWritePermissions: boolean; maxCasesToShow: number; } @@ -29,6 +30,7 @@ const RecentCasesComponent = ({ caseDetailsNavigation, createCaseNavigation, maxCasesToShow, + hasWritePermissions, }: Omit) => { const currentUser = useCurrentUser(); const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( @@ -77,6 +79,7 @@ const RecentCasesComponent = ({ createCaseNavigation={createCaseNavigation} filterOptions={recentCasesFilterOptions} maxCasesToShow={maxCasesToShow} + hasWritePermissions={hasWritePermissions} /> diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx index 0295632cc137ae..10fef0bb82df94 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.test.tsx @@ -16,11 +16,22 @@ describe('RecentCases', () => { const createCaseHref = '/create'; const wrapper = mount( - + ); expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).first().prop('href')).toEqual( createCaseHref ); }); + + it('displays a message without a link to create a case when the user does not have write permissions', () => { + const createCaseHref = '/create'; + const wrapper = mount( + + + + ); + expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="no-cases-readonly"]`).exists()).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx index df0efcec4552cc..a5b90943a219a7 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/no_cases/index.tsx @@ -10,16 +10,26 @@ import React from 'react'; import { EuiLink } from '@elastic/eui'; import * as i18n from '../translations'; -const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => ( - <> - {i18n.NO_CASES} - {` ${i18n.START_A_NEW_CASE}`} - {'!'} - -); +const NoCasesComponent = ({ + createCaseHref, + hasWritePermissions, +}: { + createCaseHref: string; + hasWritePermissions: boolean; +}) => { + return hasWritePermissions ? ( + <> + {i18n.NO_CASES} + {` ${i18n.START_A_NEW_CASE}`} + {'!'} + + ) : ( + {i18n.NO_CASES_READ_ONLY} + ); +}; NoCasesComponent.displayName = 'NoCasesComponent'; diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index 5b4313530e4904..bfe44dda6c6efc 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -31,6 +31,7 @@ export interface RecentCasesProps { caseDetailsNavigation: CasesNavigation; createCaseNavigation: CasesNavigation; maxCasesToShow: number; + hasWritePermissions: boolean; } const usePrevious = (value: Partial) => { @@ -45,6 +46,7 @@ export const RecentCasesComp = ({ createCaseNavigation, filterOptions, maxCasesToShow, + hasWritePermissions, }: RecentCasesProps) => { const previousFilterOptions = usePrevious(filterOptions); const { data, loading, setFilters } = useGetCases({ @@ -65,7 +67,7 @@ export const RecentCasesComp = ({ return isLoadingCases ? ( ) : !isLoadingCases && data.cases.length === 0 ? ( - + ) : ( <> {data.cases.map((c, i) => ( diff --git a/x-pack/plugins/cases/public/components/recent_cases/translations.ts b/x-pack/plugins/cases/public/components/recent_cases/translations.ts index c8f6c349d8f720..653bda4be2ebc0 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/recent_cases/translations.ts @@ -22,6 +22,10 @@ export const NO_CASES = i18n.translate('xpack.cases.recentCases.noCasesMessage', defaultMessage: 'No cases have been created yet. Put your detective hat on and', }); +export const NO_CASES_READ_ONLY = i18n.translate('xpack.cases.recentCases.noCasesMessageReadOnly', { + defaultMessage: 'No cases have been created yet.', +}); + export const RECENT_CASES = i18n.translate('xpack.cases.recentCases.recentCasesSidebarTitle', { defaultMessage: 'Recent cases', }); diff --git a/x-pack/plugins/cases/public/components/status/button.tsx b/x-pack/plugins/cases/public/components/status/button.tsx index 623afeb43c5965..675d83c759bc77 100644 --- a/x-pack/plugins/cases/public/components/status/button.tsx +++ b/x-pack/plugins/cases/public/components/status/button.tsx @@ -13,7 +13,6 @@ import { statuses } from './config'; interface Props { status: CaseStatuses; - disabled: boolean; isLoading: boolean; onStatusChanged: (status: CaseStatuses) => void; } @@ -21,12 +20,7 @@ interface Props { // Rotate over the statuses. open -> in-progress -> closes -> open... const getNextItem = (item: number) => (item + 1) % caseStatuses.length; -const StatusActionButtonComponent: React.FC = ({ - status, - onStatusChanged, - disabled, - isLoading, -}) => { +const StatusActionButtonComponent: React.FC = ({ status, onStatusChanged, isLoading }) => { const indexOfCurrentStatus = useMemo( () => caseStatuses.findIndex((caseStatus) => caseStatus === status), [status] @@ -41,7 +35,6 @@ const StatusActionButtonComponent: React.FC = ({ diff --git a/x-pack/plugins/cases/public/components/status/status.test.tsx b/x-pack/plugins/cases/public/components/status/status.test.tsx index 4d13e57fbdee71..a685256741c432 100644 --- a/x-pack/plugins/cases/public/components/status/status.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status.test.tsx @@ -42,17 +42,14 @@ describe('Stats', () => { ).toBe(false); }); - it('it renders with the pop over disabled when initialized disabled', async () => { + it('renders without the arrow and is not clickable when initialized disabled', async () => { const wrapper = mount( ); expect( - wrapper - .find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`) - .first() - .prop('disabled') - ).toBe(true); + wrapper.find(`[data-test-subj="status-badge-open"] .euiBadge__iconButton`).exists() + ).toBeFalsy(); }); it('it calls onClick when pressing the badge', async () => { diff --git a/x-pack/plugins/cases/public/components/status/status.tsx b/x-pack/plugins/cases/public/components/status/status.tsx index 3b832ce155400c..3c186313a151a4 100644 --- a/x-pack/plugins/cases/public/components/status/status.tsx +++ b/x-pack/plugins/cases/public/components/status/status.tsx @@ -29,18 +29,18 @@ const StatusComponent: React.FC = ({ const props = useMemo( () => ({ color: type === StatusAll ? allCaseStatus[StatusAll].color : statuses[type].color, - ...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + // if we are disabled, don't show the arrow and don't allow the user to click + ...(withArrow && !disabled ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}), + ...(!disabled ? { iconOnClick: onClick } : { iconOnClick: noop }), }), - [withArrow, type] + [disabled, onClick, withArrow, type] ); return ( {type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label} diff --git a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx index b3fbcd30d4e978..2ced7502b3c3fe 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.test.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.test.tsx @@ -8,13 +8,12 @@ import React from 'react'; import { mount } from 'enzyme'; -import { TagList } from '.'; +import { TagList, TagListProps } from '.'; import { getFormMock } from '../__mock__/form'; import { TestProviders } from '../../common/mock'; import { waitFor } from '@testing-library/react'; import { useForm } from '../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'; import { useGetTags } from '../../containers/use_get_tags'; -import { SECURITY_SOLUTION_OWNER } from '../../../common'; jest.mock('../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'); jest.mock('../../containers/use_get_tags'); @@ -33,12 +32,11 @@ jest.mock('@elastic/eui', () => { }; }); const onSubmit = jest.fn(); -const defaultProps = { - disabled: false, +const defaultProps: TagListProps = { + userCanCrud: true, isLoading: false, onSubmit, tags: [], - owner: [SECURITY_SOLUTION_OWNER], }; describe('TagList ', () => { @@ -110,15 +108,13 @@ describe('TagList ', () => { expect(wrapper.find(`[data-test-subj="tag-pepsi"]`).last().exists()).toBeTruthy(); }); - it('Renders disabled button', () => { - const props = { ...defaultProps, disabled: true }; + it('does not render when the user does not have write permissions', () => { + const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( ); - expect( - wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().prop('disabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="tag-list-edit"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/cases/public/components/tag_list/index.tsx b/x-pack/plugins/cases/public/components/tag_list/index.tsx index f2605933696796..4e8946a6589a33 100644 --- a/x-pack/plugins/cases/public/components/tag_list/index.tsx +++ b/x-pack/plugins/cases/public/components/tag_list/index.tsx @@ -27,12 +27,11 @@ import { Tags } from './tags'; const CommonUseField = getUseField({ component: Field }); -interface TagListProps { - disabled?: boolean; +export interface TagListProps { + userCanCrud?: boolean; isLoading: boolean; onSubmit: (a: string[]) => void; tags: string[]; - owner: string[]; } const MyFlexGroup = styled(EuiFlexGroup)` @@ -45,7 +44,7 @@ const MyFlexGroup = styled(EuiFlexGroup)` `; export const TagList = React.memo( - ({ disabled = false, isLoading, onSubmit, tags, owner }: TagListProps) => { + ({ userCanCrud = true, isLoading, onSubmit, tags }: TagListProps) => { const initialState = { tags }; const { form } = useForm({ defaultValue: initialState, @@ -86,11 +85,10 @@ export const TagList = React.memo(

{i18n.TAGS}

{isLoading && } - {!isLoading && ( + {!isLoading && userCanCrud && ( { expect(errorsMsg[0].id).toEqual('closed-case-push-error'); }); }); + + describe('user does not have write permissions', () => { + const noWriteProps = { ...defaultArgs, userCanCrud: false }; + + it('does not display a message when user does not have a premium license', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInLicense: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have case enabled in config', async () => { + (useGetActionLicense as jest.Mock).mockImplementation(() => ({ + isLoading: false, + actionLicense: { + ...actionLicense, + enabledInConfig: false, + }, + })); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => usePushToService(noWriteProps), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does not have any connector configured', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when user does have a connector but is configured to none', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when connector is deleted with empty connectors', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + connectors: [], + connector: { + id: 'not-exist', + name: 'not-exist', + type: ConnectorTypes.none, + fields: null, + }, + isValidConnector: false, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + + it('does not display a message when case is closed', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => + usePushToService({ + ...noWriteProps, + caseStatus: CaseStatuses.closed, + }), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + expect(result.current.pushCallouts).toBeNull(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx index 00b88d372584b6..6f711150b77443 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.tsx @@ -67,9 +67,17 @@ export const usePushToService = ({ const errorsMsg = useMemo(() => { let errors: ErrorMessage[] = []; + + // these message require that the user do some sort of write action as a result of the message, readonly users won't + // be able to perform such an action so let's not display the error to the user in that situation + if (!userCanCrud) { + return errors; + } + if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [...errors, getLicenseError()]; } + if (connectors.length === 0 && connector.id === 'none' && !loadingLicense) { errors = [ ...errors, @@ -136,12 +144,13 @@ export const usePushToService = ({ }, ]; } + if (actionLicense != null && !actionLicense.enabledInConfig) { errors = [...errors, getKibanaConfigError()]; } return errors; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense]); + }, [actionLicense, caseStatus, connectors.length, connector, loadingLicense, userCanCrud]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx index f9bd941547078e..c7cc71da929477 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/index.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/index.tsx @@ -241,7 +241,7 @@ export const UserActionTree = React.memo( () => ( ), }), @@ -363,10 +363,10 @@ export const UserActionTree = React.memo( id={comment.id} editLabel={i18n.EDIT_COMMENT} quoteLabel={i18n.QUOTE} - disabled={!userCanCrud} isLoading={isLoadingIds.includes(comment.id)} onEdit={handleManageMarkdownEditId.bind(null, comment.id)} onQuote={handleManageQuote.bind(null, comment.comment)} + userCanCrud={userCanCrud} /> ), }, @@ -571,19 +571,24 @@ export const UserActionTree = React.memo( ] ); - const bottomActions = [ - { - username: ( - - ), - 'data-test-subj': 'add-comment', - timelineIcon: ( - - ), - className: 'isEdit', - children: MarkdownNewComment, - }, - ]; + const bottomActions = userCanCrud + ? [ + { + username: ( + + ), + 'data-test-subj': 'add-comment', + timelineIcon: ( + + ), + className: 'isEdit', + children: MarkdownNewComment, + }, + ] + : []; const comments = [...userActions, ...bottomActions]; diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx index a5244e14ad2432..155e9e2323e645 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.test.tsx @@ -7,7 +7,10 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { UserActionContentToolbar } from './user_action_content_toolbar'; +import { + UserActionContentToolbar, + UserActionContentToolbarProps, +} from './user_action_content_toolbar'; jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -28,12 +31,12 @@ jest.mock('../../common/lib/kibana', () => ({ }), })); -const props = { +const props: UserActionContentToolbarProps = { getCaseDetailHrefWithCommentId: jest.fn().mockReturnValue('case-detail-url-with-comment-id-1'), id: '1', editLabel: 'edit', quoteLabel: 'quote', - disabled: false, + userCanCrud: true, isLoading: false, onEdit: jest.fn(), onQuote: jest.fn(), diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx index 7adaffce22c54a..5fa12b8cfa4344 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_content_toolbar.tsx @@ -11,15 +11,15 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { UserActionCopyLink } from './user_action_copy_link'; import { UserActionPropertyActions } from './user_action_property_actions'; -interface UserActionContentToolbarProps { +export interface UserActionContentToolbarProps { id: string; getCaseDetailHrefWithCommentId: (commentId: string) => string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; + userCanCrud: boolean; } const UserActionContentToolbarComponent = ({ @@ -27,26 +27,27 @@ const UserActionContentToolbarComponent = ({ getCaseDetailHrefWithCommentId, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, + userCanCrud, }: UserActionContentToolbarProps) => ( - - - + {userCanCrud && ( + + + + )} ); diff --git a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx index 44b5baf3246cc7..ebc83de1ef36a1 100644 --- a/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx +++ b/x-pack/plugins/cases/public/components/user_action_tree/user_action_property_actions.tsx @@ -14,7 +14,6 @@ interface UserActionPropertyActionsProps { id: string; editLabel: string; quoteLabel: string; - disabled: boolean; isLoading: boolean; onEdit: (id: string) => void; onQuote: (id: string) => void; @@ -24,7 +23,6 @@ const UserActionPropertyActionsComponent = ({ id, editLabel, quoteLabel, - disabled, isLoading, onEdit, onQuote, @@ -35,19 +33,17 @@ const UserActionPropertyActionsComponent = ({ const propertyActions = useMemo( () => [ { - disabled, iconType: 'pencil', label: editLabel, onClick: onEditClick, }, { - disabled, iconType: 'quote', label: quoteLabel, onClick: onQuoteClick, }, ], - [disabled, editLabel, quoteLabel, onEditClick, onQuoteClick] + [editLabel, quoteLabel, onEditClick, onQuoteClick] ); return ( <> diff --git a/x-pack/plugins/cases/public/containers/configure/translations.ts b/x-pack/plugins/cases/public/containers/configure/translations.ts index e77b9f57c8f4c7..01900b8850c195 100644 --- a/x-pack/plugins/cases/public/containers/configure/translations.ts +++ b/x-pack/plugins/cases/public/containers/configure/translations.ts @@ -12,3 +12,11 @@ export * from '../translations'; export const SUCCESS_CONFIGURE = i18n.translate('xpack.cases.configure.successSaveToast', { defaultMessage: 'Saved external connection settings', }); + +export const READ_PERMISSIONS_ERROR_MSG = i18n.translate( + 'xpack.cases.configure.readPermissionsErrorDescription', + { + defaultMessage: + 'You do not have permissions to view connectors. If you would like to view the connectors associated with this case, contact your Kibana administrator.', + } +); diff --git a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx index 3b91c77d0235a0..e350146c650ce3 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_connectors.tsx @@ -7,26 +7,40 @@ import { useState, useEffect, useCallback, useRef } from 'react'; -import * as i18n from '../translations'; import { fetchConnectors } from './api'; import { ActionConnector } from './types'; import { useToasts } from '../../common/lib/kibana'; +import * as i18n from './translations'; + +interface ConnectorsState { + loading: boolean; + connectors: ActionConnector[]; + permissionsError?: string; +} export interface UseConnectorsResponse { loading: boolean; connectors: ActionConnector[]; refetchConnectors: () => void; + permissionsError?: string; } -export const useConnectors = (): UseConnectorsResponse => { +/** + * Retrieves the configured case connectors + * + * @param toastPermissionsErrors boolean controlling whether 403 and 401 errors should be displayed in a toast error + */ +export const useConnectors = ({ + toastPermissionsErrors = true, +}: { + toastPermissionsErrors?: boolean; +} = {}): UseConnectorsResponse => { const toasts = useToasts(); - const [state, setState] = useState<{ - loading: boolean; - connectors: ActionConnector[]; - }>({ + const [state, setState] = useState({ loading: true, connectors: [], }); + const isCancelledRef = useRef(false); const abortCtrlRef = useRef(new AbortController()); @@ -49,15 +63,26 @@ export const useConnectors = (): UseConnectorsResponse => { } } catch (error) { if (!isCancelledRef.current) { + let permissionsError: string | undefined; if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); + // if the error was related to permissions then let's return a boilerplate error message describing the problem + if (error.body?.statusCode === 403 || error.body?.statusCode === 401) { + permissionsError = i18n.READ_PERMISSIONS_ERROR_MSG; + } + + // if the error was not permissions related then toast it + // if it was permissions related (permissionsError was defined) and the caller wants to toast, then create a toast + if (permissionsError === undefined || toastPermissionsErrors) { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } } setState({ loading: false, connectors: [], + permissionsError, }); } } @@ -77,5 +102,6 @@ export const useConnectors = (): UseConnectorsResponse => { loading: state.loading, connectors: state.connectors, refetchConnectors, + permissionsError: state.permissionsError, }; }; diff --git a/x-pack/plugins/cases/public/mocks.ts b/x-pack/plugins/cases/public/mocks.ts new file mode 100644 index 00000000000000..c543baa4774756 --- /dev/null +++ b/x-pack/plugins/cases/public/mocks.ts @@ -0,0 +1,21 @@ +/* + * 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 { CasesUiStart } from './types'; + +const createStartContract = (): jest.Mocked => ({ + getAllCases: jest.fn(), + getAllCasesSelectorModal: jest.fn(), + getCaseView: jest.fn(), + getConfigureCases: jest.fn(), + getCreateCase: jest.fn(), + getRecentCases: jest.fn(), +}); + +export const casesPluginMock = { + createStartContract, +}; diff --git a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts index d54b5164b10b94..48c6e9ebcd07a8 100644 --- a/x-pack/plugins/cases/server/authorization/audit_logger.test.ts +++ b/x-pack/plugins/cases/server/authorization/audit_logger.test.ts @@ -143,7 +143,7 @@ describe('audit_logger', () => { // for reference: https://github.com/facebook/jest/issues/9409#issuecomment-629272237 // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error or entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -156,7 +156,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error but no entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -170,7 +170,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" with an error and entity`, (operationKey) => { // forcing the cast here because using a string throws a type error @@ -188,7 +188,7 @@ describe('audit_logger', () => { ); // This loops through all operation keys - it.each(Array.from(Object.keys(Operations)))( + it.each(Object.keys(Operations))( `creates the correct audit event for operation: "%s" without an error but with an entity`, (operationKey) => { // forcing the cast here because using a string throws a type error diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 28b9cf9e4e032a..b1e2f61a595eef 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -72,7 +72,7 @@ export class CasePlugin { this.clientFactory = new CasesClientFactory(this.log); } - public async setup(core: CoreSetup, plugins: PluginsSetup) { + public setup(core: CoreSetup, plugins: PluginsSetup) { const config = createConfig(this.initializerContext); if (!config.enabled) { diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx +++ b/x-pack/plugins/observability/public/components/app/cases/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts index cb7236b445be12..20bb57daf5841b 100644 --- a/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.observability.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.observability.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/observability/public/components/app/cases/translations.ts b/x-pack/plugins/observability/public/components/app/cases/translations.ts index 1a5abe218edf52..a85b0bc744e66a 100644 --- a/x-pack/plugins/observability/public/components/app/cases/translations.ts +++ b/x-pack/plugins/observability/public/components/app/cases/translations.ts @@ -201,3 +201,17 @@ export const CONNECTORS = i18n.translate('xpack.observability.cases.caseView.con export const EDIT_CONNECTOR = i18n.translate('xpack.observability.cases.caseView.editConnector', { defaultMessage: 'Change external incident management system', }); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.observability.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.observability.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx b/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx new file mode 100644 index 00000000000000..4d8779e1ea150a --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/use_readonly_header.tsx @@ -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 { useCallback, useEffect } from 'react'; + +import * as i18n from '../components/app/cases/translations'; +import { useGetUserCasesPermissions } from '../hooks/use_get_user_cases_permissions'; +import { useKibana } from '../utils/kibana_react'; + +/** + * This component places a read-only icon badge in the header if user only has read permissions + */ +export function useReadonlyHeader() { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + // if the user is read only then display the glasses badge in the global navigation header + const setBadge = useCallback(() => { + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + }, [chrome, userPermissions]); + + useEffect(() => { + setBadge(); + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [setBadge, chrome]); +} diff --git a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx index f73f3b4cf57d75..442104a7106017 100644 --- a/x-pack/plugins/observability/public/pages/cases/all_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/all_cases.tsx @@ -10,35 +10,28 @@ import React from 'react'; import { AllCases } from '../../components/app/cases/all_cases'; import * as i18n from '../../components/app/cases/translations'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../../components/app/cases/callout'; import { CaseFeatureNoPermissions } from './feature_no_permissions'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; import { casesBreadcrumbs } from './links'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; export const AllCasesPage = React.memo(() => { const userPermissions = useGetUserCasesPermissions(); const { ObservabilityPageTemplate } = usePluginContext(); + useReadonlyHeader(); useBreadcrumbs([casesBreadcrumbs.cases]); return userPermissions == null || userPermissions?.read ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - {i18n.PAGE_TITLE}, - }} - > - - - + {i18n.PAGE_TITLE}, + }} + > + + ) : ( ); diff --git a/x-pack/plugins/observability/public/pages/cases/case_details.tsx b/x-pack/plugins/observability/public/pages/cases/case_details.tsx index 6adf5ad286808f..f93cb5c4e7919a 100644 --- a/x-pack/plugins/observability/public/pages/cases/case_details.tsx +++ b/x-pack/plugins/observability/public/pages/cases/case_details.tsx @@ -5,45 +5,35 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { CaseView } from '../../components/app/cases/case_view'; import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions'; import { useKibana } from '../../utils/kibana_react'; import { CASES_APP_ID } from '../../components/app/cases/constants'; -import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/app/cases/callout'; +import { useReadonlyHeader } from '../../hooks/use_readonly_header'; export const CaseDetailsPage = React.memo(() => { const { application: { getUrlForApp, navigateToUrl }, } = useKibana().services; + const casesUrl = getUrlForApp(CASES_APP_ID); const userPermissions = useGetUserCasesPermissions(); const { detailName: caseId, subCaseId } = useParams<{ detailName?: string; subCaseId?: string; }>(); + useReadonlyHeader(); - const casesUrl = getUrlForApp(CASES_APP_ID); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return caseId != null ? ( - <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} - - + ) : null; }); diff --git a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx index a4df4855b0204d..9676eb7eba1470 100644 --- a/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx +++ b/x-pack/plugins/observability/public/pages/cases/configure_cases.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import styled from 'styled-components'; import { EuiButtonEmpty } from '@elastic/eui'; @@ -38,10 +38,12 @@ function ConfigureCasesPageComponent() { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]); - if (userPermissions != null && !userPermissions.read) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToUrl(casesUrl); + } + }, [casesUrl, userPermissions, navigateToUrl]); return ( { const { formatUrl } = useFormatUrl(CASES_APP_ID); const href = formatUrl(getCaseUrl()); useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]); - if (userPermissions != null && !userPermissions.crud) { - navigateToUrl(casesUrl); - return null; - } + + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToUrl(casesUrl); + } + }, [casesUrl, navigateToUrl, userPermissions]); return ( { }); it('should not allow user with read only privileges to attach alerts to cases', () => { - cy.get(ATTACH_ALERT_TO_CASE_BUTTON).first().should('be.disabled'); + cy.get(ATTACH_ALERT_TO_CASE_BUTTON).should('not.exist'); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx index 29b17cd426c58b..fdd49ad17168de 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/callout/helpers.tsx @@ -5,18 +5,7 @@ * 2.0. */ -import React from 'react'; import md5 from 'md5'; -import * as i18n from './translations'; -import { ErrorMessage } from './types'; - -export const permissionsReadOnlyErrorMessage: ErrorMessage = { - id: 'read-only-privileges-error', - title: i18n.READ_ONLY_FEATURE_TITLE, - description: <>{i18n.READ_ONLY_FEATURE_MSG}, - errorType: 'warning', -}; - export const createCalloutId = (ids: string[], delimiter: string = '|'): string => md5(ids.join(delimiter)); diff --git a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts index db4809126452f9..617995cc366b06 100644 --- a/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/callout/translations.ts @@ -7,21 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const READ_ONLY_FEATURE_TITLE = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureTitle', - { - defaultMessage: 'You cannot open new or update existing cases', - } -); - -export const READ_ONLY_FEATURE_MSG = i18n.translate( - 'xpack.securitySolution.cases.readOnlyFeatureDescription', - { - defaultMessage: - 'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.', - } -); - export const DISMISS_CALLOUT = i18n.translate( 'xpack.securitySolution.cases.dismissErrorsPushServiceCallOutTitle', { diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx index 77fa9e8b3cc8c9..02047c774ca6f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.test.tsx @@ -200,7 +200,7 @@ describe('AddToCaseAction', () => { ).toBeTruthy(); }); - it('disabled when user does not have crud permissions', () => { + it('hides the icon when user does not have crud permissions', () => { (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ crud: false, read: true, @@ -212,8 +212,6 @@ describe('AddToCaseAction', () => { ); - expect( - wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().prop('isDisabled') - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).exists()).toBeFalsy(); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index eaad912a4dc51c..7025bff1ce49a6 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -208,19 +208,21 @@ const AddToCaseActionComponent: React.FC = ({ return ( <> - - - - - + {userCanCrud && ( + + + + + + )} {isCreateCaseFlyoutOpen && ( { return userPermissions == null || userPermissions?.read ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} diff --git a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx index 73077334268626..a086409e55df52 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/case_details.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { SecurityPageName } from '../../app/types'; @@ -16,7 +16,6 @@ import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; import { getCaseUrl } from '../../common/components/link_to'; import { navTabs } from '../../app/home/home_navigations'; import { CaseView } from '../components/case_view'; -import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout'; import { CASES_APP_ID } from '../../../common/constants'; export const CaseDetailsPage = React.memo(() => { @@ -30,20 +29,15 @@ export const CaseDetailsPage = React.memo(() => { }>(); const search = useGetUrlSearch(navTabs.case); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); + } + }, [navigateToApp, userPermissions, search]); return caseId != null ? ( <> - {userPermissions != null && !userPermissions?.crud && userPermissions?.read && ( - - )} { [search] ); - if (userPermissions != null && !userPermissions.read) { - navigateToApp(CASES_APP_ID, { path: getCaseUrl(search) }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.read) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [navigateToApp, userPermissions, search]); const HeaderWrapper = styled.div` padding-top: ${({ theme }) => theme.eui.paddingSizes.l}; diff --git a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx index 19f97bae60ebe9..3c5197f19eff12 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/create_case.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { SecurityPageName } from '../../app/types'; import { getCaseUrl } from '../../common/components/link_to'; @@ -25,6 +25,7 @@ export const CreateCasePage = React.memo(() => { const { application: { navigateToApp }, } = useKibana().services; + const backOptions = useMemo( () => ({ href: getCaseUrl(search), @@ -34,12 +35,13 @@ export const CreateCasePage = React.memo(() => { [search] ); - if (userPermissions != null && !userPermissions.crud) { - navigateToApp(CASES_APP_ID, { - path: getCaseUrl(search), - }); - return null; - } + useEffect(() => { + if (userPermissions != null && !userPermissions.crud) { + navigateToApp(CASES_APP_ID, { + path: getCaseUrl(search), + }); + } + }, [userPermissions, navigateToApp, search]); return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx new file mode 100644 index 00000000000000..0d12d63fdc244c --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/pages/index.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 { mount } from 'enzyme'; +import { BrowserRouter as Router } from 'react-router-dom'; + +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; +import { TestProviders } from '../../common/mock'; +import { Case } from '.'; + +const useKibanaMock = useKibana as jest.Mocked; +jest.mock('../../common/lib/kibana'); + +const mockedSetBadge = jest.fn(); + +describe('CaseContainerComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock().services.chrome.setBadge = mockedSetBadge; + }); + + it('does not display the readonly glasses badge when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has neither write nor read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('does not display the readonly glasses badge when the user has null permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(null); + + mount( + + + + + + ); + + expect(mockedSetBadge).not.toBeCalled(); + }); + + it('displays the readonly glasses badge read permissions but not write', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + mount( + + + + + + ); + + expect(mockedSetBadge).toBeCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 314bdc9bfd117f..fca19cf5c70a7e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -5,13 +5,15 @@ * 2.0. */ -import React from 'react'; - +import React, { useEffect } from 'react'; import { Route, Switch } from 'react-router-dom'; + +import * as i18n from './translations'; import { CaseDetailsPage } from './case_details'; import { CasesPage } from './case'; import { CreateCasePage } from './create_case'; import { ConfigureCasesPage } from './configure_cases'; +import { useGetUserCasesPermissions, useKibana } from '../../common/lib/kibana'; const casesPagePath = ''; const caseDetailsPagePath = `${casesPagePath}/:detailName`; @@ -21,30 +23,51 @@ const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentI const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; -const CaseContainerComponent: React.FC = () => ( - - - - - - - - - - - - - - - - - - - - - - - -); +const CaseContainerComponent: React.FC = () => { + const userPermissions = useGetUserCasesPermissions(); + const chrome = useKibana().services.chrome; + + useEffect(() => { + // if the user is read only then display the glasses badge in the global navigation header + if (userPermissions != null && !userPermissions.crud && userPermissions.read) { + chrome.setBadge({ + text: i18n.READ_ONLY_BADGE_TEXT, + tooltip: i18n.READ_ONLY_BADGE_TOOLTIP, + iconType: 'glasses', + }); + } + + // remove the icon after the component unmounts + return () => { + chrome.setBadge(); + }; + }, [userPermissions, chrome]); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; export const Case = React.memo(CaseContainerComponent); diff --git a/x-pack/plugins/security_solution/public/cases/pages/translations.ts b/x-pack/plugins/security_solution/public/cases/pages/translations.ts index 1a811a3fd7bbc3..6768401b3f608e 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/pages/translations.ts @@ -157,3 +157,24 @@ export const GO_TO_DOCUMENTATION = i18n.translate( export const CONNECTORS = i18n.translate('xpack.securitySolution.cases.caseView.connectors', { defaultMessage: 'External Incident Management System', }); + +export const EDIT_CONNECTOR = i18n.translate( + 'xpack.securitySolution.cases.caseView.editConnector', + { + defaultMessage: 'Change external incident management system', + } +); + +export const READ_ONLY_BADGE_TEXT = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.text', + { + defaultMessage: 'Read only', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.cases.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create or edit cases', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx new file mode 100644 index 00000000000000..96a7eacb7fb08c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 { mount } from 'enzyme'; + +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; +import { TestProviders } from '../../../common/mock'; +import { HeaderGlobal } from '.'; + +jest.mock('../../../common/lib/kibana'); + +describe('HeaderGlobal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not display the cases tab when the user does not have read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeFalsy(); + }); + + it('displays the cases tab when the user has read permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: true, + }); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 4a7ac8a148f647..e91905183aab10 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -19,7 +19,7 @@ import { MlPopover } from '../ml_popover/ml_popover'; import { SiemNavigation } from '../navigation'; import * as i18n from './translations'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; -import { useKibana } from '../../lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana'; import { APP_ID, ADD_DATA_PATH, APP_DETECTIONS_PATH } from '../../../../common/constants'; import { useGlobalHeaderPortal } from '../../hooks/use_global_header_portal'; import { LinkAnchor } from '../links'; @@ -91,6 +91,18 @@ export const HeaderGlobal = React.memo( }, [navigateToApp, search] ); + + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + + // build a list of tabs to exclude + const tabsToExclude = new Set([ + ...(hideDetectionEngine ? [SecurityPageName.detections] : []), + ...(!hasCasesReadPermissions ? [SecurityPageName.case] : []), + ]); + + // include the tab if it is not in the set of excluded ones + const tabsToDisplay = pickBy((_, key) => !tabsToExclude.has(key), navTabs); + return ( @@ -109,14 +121,7 @@ export const HeaderGlobal = React.memo( - key !== SecurityPageName.detections, navTabs) - : navTabs - } - /> + diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx index 996835296fcc4b..cb7733e3049850 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_cases/index.tsx @@ -13,7 +13,7 @@ import { getCreateCaseUrl, } from '../../../common/components/link_to/redirect_to_case'; import { useFormatUrl } from '../../../common/components/link_to'; -import { useKibana } from '../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; import { APP_ID, CASES_APP_ID } from '../../../../common/constants'; import { SecurityPageName } from '../../../app/types'; import { AllCasesNavProps } from '../../../cases/components/all_cases'; @@ -26,6 +26,8 @@ const RecentCasesComponent = () => { application: { navigateToApp }, } = useKibana().services; + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return casesUi.getRecentCases({ allCasesNavigation: { href: formatUrl(getCaseUrl()), @@ -60,6 +62,7 @@ const RecentCasesComponent = () => { }); }, }, + hasWritePermissions, maxCasesToShow: MAX_CASES_TO_SHOW, owner: [APP_ID], }); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx new file mode 100644 index 00000000000000..76c5663644a789 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { mount } from 'enzyme'; +import { waitFor } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; +import { Sidebar } from './sidebar'; +import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana'; +import { casesPluginMock } from '../../../../../cases/public/mocks'; +import { CasesUiStart } from '../../../../../cases/public'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.MockedFunction; + +describe('Sidebar', () => { + let casesMock: jest.Mocked; + + beforeEach(() => { + casesMock = casesPluginMock.createStartContract(); + casesMock.getRecentCases.mockImplementation(() => <>{'test'}); + useKibanaMock.mockReturnValue(({ + services: { + cases: casesMock, + application: { + // these are needed by the RecentCases component if it is rendered. + navigateToApp: jest.fn(), + getUrlForApp: jest.fn(() => ''), + }, + }, + } as unknown) as ReturnType); + }); + + it('does not render the recently created cases section when the user does not have read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).not.toHaveBeenCalled(); + }); + + it('does render the recently created cases section when the user has read permissions', async () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: true, + }); + + await waitFor(() => + mount( + + {}} /> + + ) + ); + + expect(casesMock.getRecentCases).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx index 77cfa220f07220..b8701f3ef1639d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/sidebar/sidebar.tsx @@ -18,6 +18,7 @@ import { SidebarHeader } from '../../../common/components/sidebar_header'; import * as i18n from '../../pages/translations'; import { RecentCases } from '../recent_cases'; +import { useGetUserCasesPermissions } from '../../../common/lib/kibana'; const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; @@ -46,13 +47,20 @@ export const Sidebar = React.memo<{ [recentTimelinesFilterBy, setRecentTimelinesFilterBy] ); + // only render the recently created cases view if the user has at least read permissions + const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; + return ( - - - + {hasCasesReadPermissions && ( + <> + + + - + + + )} {recentTimelinesFilters} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx index 68b4f2e4a0c31c..206fcb2dc087ca 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useGetUserCasesPermissions } from '../../../../common/lib/kibana'; import { TestProviders, mockIndexNames, mockIndexPattern } from '../../../../common/mock'; import { TimelineId } from '../../../../../common/types/timeline'; import { useTimelineKpis } from '../../../containers/kpis'; @@ -57,7 +57,7 @@ const defaultMocks = { loading: false, selectedPatterns: mockIndexNames, }; -describe('Timeline KPIs', () => { +describe('header', () => { const mount = useMountAppended(); beforeEach(() => { @@ -75,86 +75,124 @@ describe('Timeline KPIs', () => { jest.clearAllMocks(); }); - describe('when the data is not loading and the response contains data', () => { + describe('AddToCaseButton', () => { beforeEach(() => { mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); }); - it('renders the component, labels and values succesfully', async () => { + + it('renders the button when the user has write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: true, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); - // label - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - // value - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1') - ); - }); - }); - describe('when the data is loading', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeTruthy(); }); - it('renders a loading indicator for values', async () => { + + it('does not render the button when the user does not have write permissions', () => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue({ + crud: false, + read: false, + }); + const wrapper = mount( ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('--') - ); + + expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeFalsy(); }); }); - describe('when the response is null and timeline is blank', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, null]); + describe('Timeline KPIs', () => { + describe('when the data is not loading and the response contains data', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineKpiResponse]); + }); + it('renders the component, labels and values successfully', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-kpis"]').exists()).toEqual(true); + // label + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + // value + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1') + ); + }); }); - it('renders labels and the default empty string', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('Processes') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining(getEmptyValue()) - ); + describe('when the data is loading', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]); + }); + it('renders a loading indicator for values', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('--') + ); + }); }); - }); - describe('when the response contains numbers larger than one thousand', () => { - beforeEach(() => { - mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + describe('when the response is null and timeline is blank', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, null]); + }); + it('renders labels and the default empty string', async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('Processes') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining(getEmptyValue()) + ); + }); }); - it('formats the numbers correctly', async () => { - const wrapper = mount( - - - - ); - expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( - expect.stringContaining('1k') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( - expect.stringContaining('1m') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text()).toEqual( - expect.stringContaining('1b') - ); - expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( - expect.stringContaining('999') - ); + + describe('when the response contains numbers larger than one thousand', () => { + beforeEach(() => { + mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]); + }); + it('formats the numbers correctly', async () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual( + expect.stringContaining('1k') + ); + expect(wrapper.find('[data-test-subj="siem-timeline-user-kpi"]').first().text()).toEqual( + expect.stringContaining('1m') + ); + expect( + wrapper.find('[data-test-subj="siem-timeline-source-ip-kpi"]').first().text() + ).toEqual(expect.stringContaining('1b')); + expect(wrapper.find('[data-test-subj="siem-timeline-host-kpi"]').first().text()).toEqual( + expect.stringContaining('999') + ); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index dd8cdb818cad75..216282b72920c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -35,7 +35,7 @@ import { TimerangeInput } from '../../../../../common/search_strategy'; import { AddToCaseButton } from '../add_to_case_button'; import { AddTimelineButton } from '../add_timeline_button'; import { SaveTimelineButton } from '../../timeline/header/save_timeline_button'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useGetUserCasesPermissions, useKibana } from '../../../../common/lib/kibana'; import { InspectButton } from '../../../../common/components/inspect'; import { useTimelineKpis } from '../../../containers/kpis'; import { esQuery } from '../../../../../../../../src/plugins/data/public'; @@ -319,6 +319,8 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { filterQuery: combinedQueries?.filterQuery ?? '', }); + const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false; + return ( @@ -350,9 +352,11 @@ const FlyoutHeaderComponent: React.FC = ({ timelineId }) => { - - - + {hasWritePermissions && ( + + + + )} diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 7e4d0989af413d..ac9d854f18211f 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -129,17 +129,23 @@ export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} -const securitySubPlugins = [ +const casesSubPlugin = `${APP_ID}:${SecurityPageName.case}`; + +/** + * Don't include cases here so that the sub feature can govern whether Cases is enabled in the navigation + */ +const securitySubPluginsNoCases = [ APP_ID, `${APP_ID}:${SecurityPageName.overview}`, `${APP_ID}:${SecurityPageName.detections}`, `${APP_ID}:${SecurityPageName.hosts}`, `${APP_ID}:${SecurityPageName.network}`, `${APP_ID}:${SecurityPageName.timelines}`, - `${APP_ID}:${SecurityPageName.case}`, `${APP_ID}:${SecurityPageName.administration}`, ]; +const allSecuritySubPlugins = [...securitySubPluginsNoCases, casesSubPlugin]; + export class Plugin implements IPlugin { private readonly logger: Logger; private readonly config: ConfigType; @@ -305,7 +311,7 @@ export class Plugin implements IPlugin { await PageObjects.common.navigateToActualUrl('observabilityCases'); - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); - it(`shows read-only callout`, async () => { - await PageObjects.observability.expectReadOnlyCallout(); + it(`shows read-only glasses badge`, async () => { + await PageObjects.observability.expectReadOnlyGlassesBadge(); }); it(`does not allow a case to be created`, async () => { @@ -151,7 +151,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); // expect redirection to observability cases landing - await PageObjects.observability.expectCreateCaseButtonDisabled(); + await PageObjects.observability.expectCreateCaseButtonMissing(); }); it(`does not allow a case to be edited`, async () => { @@ -162,7 +162,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, } ); - await PageObjects.observability.expectAddCommentButtonDisabled(); + await PageObjects.observability.expectAddCommentButtonMissing(); }); }); diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index 95016c31d10541..d9e413d473adf3 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -20,14 +20,12 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectCreateCaseButtonDisabled() { - const button = await testSubjects.find('createNewCaseBtn', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectCreateCaseButtonMissing() { + await testSubjects.missingOrFail('createNewCaseBtn'); }, - async expectReadOnlyCallout() { - await testSubjects.existOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3'); + async expectReadOnlyGlassesBadge() { + await testSubjects.existOrFail('headerBadge'); }, async expectNoReadOnlyCallout() { @@ -44,10 +42,8 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro expect(disabledAttr).to.be(null); }, - async expectAddCommentButtonDisabled() { - const button = await testSubjects.find('submit-comment', 20000); - const disabledAttr = await button.getAttribute('disabled'); - expect(disabledAttr).to.be('true'); + async expectAddCommentButtonMissing() { + await testSubjects.missingOrFail('submit-comment'); }, async expectForbidden() {