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 19c303840fc1a..078db1e6dbe6d 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 04104f0b9471d..6604f3d2b8bc8 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 (
);
}
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 7452fe7e44b3c..73dcc18b97108 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 e29551f43c2bd..b8755d03e0b00 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 0f535b771ec8a..8da90f32fabdf 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 29b17cd426c58..fdd49ad17168d 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 dca622e60c863..8b0ad31dba88e 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 c2578dc3debdb..6816575d649f7 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 724d35b20df53..3040b0fe47a47 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 d8e012b072106..3448d112dadd1 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 df57e49073a60..05f1c6727b168 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 1385e8e8664c3..33efb7e447583 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 ad6b5a5e7cddf..570f6e34d2528 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 0295632cc137a..10fef0bb82df9 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 df0efcec4552c..a5b90943a219a 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 5b4313530e490..bfe44dda6c6ef 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 c8f6c349d8f72..653bda4be2ebc 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 623afeb43c596..675d83c759bc7 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 4d13e57fbdee7..a685256741c43 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 3b832ce155400..3c186313a151a 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 b3fbcd30d4e97..2ced7502b3c3f 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 f260593369679..4e8946a6589a3 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 00b88d372584b..6f711150b7744 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 f9bd941547078..c7cc71da92947 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 a5244e14ad243..155e9e2323e64 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 7adaffce22c54..5fa12b8cfa434 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 44b5baf3246cc..ebc83de1ef36a 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 e77b9f57c8f4c..01900b8850c19 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 3b91c77d0235a..e350146c650ce 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 0000000000000..c543baa477475
--- /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 d54b5164b10b9..48c6e9ebcd07a 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 28b9cf9e4e032..b1e2f61a595ee 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 29b17cd426c58..fdd49ad17168d 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 cb7236b445be1..20bb57daf5841 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 1a5abe218edf5..a85b0bc744e66 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 0000000000000..4d8779e1ea150
--- /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 f73f3b4cf57d7..442104a710601 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 6adf5ad286808..f93cb5c4e7919 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 a4df4855b0204..9676eb7eba147 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 29b17cd426c58..fdd49ad17168d 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 db4809126452f..617995cc366b0 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 77fa9e8b3cc8c..02047c774ca6f 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 eaad912a4dc51..7025bff1ce49a 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 7307733426862..a086409e55df5 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 19f97bae60ebe..3c5197f19eff1 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 0000000000000..0d12d63fdc244
--- /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 314bdc9bfd117..fca19cf5c70a7 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 1a811a3fd7bbc..6768401b3f608 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 0000000000000..96a7eacb7fb08
--- /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 4a7ac8a148f64..e91905183aab1 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 996835296fcc4..cb7733e304985 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 0000000000000..76c5663644a78
--- /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 77cfa220f0722..b8701f3ef1639 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 68b4f2e4a0c31..206fcb2dc087c 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 dd8cdb818cad7..216282b72920c 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 a0f466512cc1d..b3d37958a324e 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;
@@ -303,7 +309,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 95016c31d1054..d9e413d473adf 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() {