mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[Cases] RBAC Bugs (#101325)
* Adding feature flag for auth * Hiding SOs and adding consumer field * First pass at adding security changes * Consumer as the app's plugin ID * Create addConsumerToSO migration helper * Fix mapping's SO consumer * Add test for CasesActions * Declare hidden types on SO client * Restructure integration tests * Init spaces_only integration tests * Implementing the cases security string * Adding security plugin tests for cases * Rough concept for authorization class * Adding comments * Fix merge * Get requiredPrivileges for classes * Check privillages * Ensure that all classes are available * Success if hasAllRequested is true * Failure if hasAllRequested is false * Adding schema updates for feature plugin * Seperate basic from trial * Enable SIR on integration tests * Starting the plumbing for authorization in plugin * Unit tests working * Move find route logic to case client * Create integration test helper functions * Adding auth to create call * Create getClassFilter helper * Add class attribute to find request * Create getFindAuthorizationFilter * Ensure savedObject is authorized in find method * Include fields for authorization * Combine authorization filter with cases & subcases filter * Fix isAuthorized flag * Fix merge issue * Create/delete spaces & users before and after tests * Add more user and roles * [Cases] Convert filters from strings to KueryNode (#95288) * [Cases] RBAC: Rename class to scope (#95535) * [Cases][RBAC] Rename scope to owner (#96035) * [Cases] RBAC: Create & Find integration tests (#95511) * [Cases] Cases client enchantment (#95923) * [Cases] Authorization and Client Audit Logger (#95477) * Starting audit logger * Finishing auth audit logger * Fixing tests and types * Adding audit event creator * Renaming class to scope * Adding audit logger messages to create and find * Adding comments and fixing import issue * Fixing type errors * Fixing tests and adding username to message * Addressing PR feedback * Removing unneccessary log and generating id * Fixing module issue and remove expect.anything * [Cases] Migrate sub cases routes to a client (#96461) * Adding sub cases client * Move sub case routes to case client * Throw when attempting to access the sub cases client * Fixing throw and removing user ans soclients * [Cases] RBAC: Migrate routes' unit tests to integration tests (#96374) Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co> * [Cases] Move remaining HTTP functionality to client (#96507) * Moving deletes and find for attachments * Moving rest of comment apis * Migrating configuration routes to client * Finished moving routes, starting utils refactor * Refactoring utilites and fixing integration tests * Addressing PR feedback * Fixing mocks and types * Fixing integration tests * Renaming status_stats * Fixing test type errors * Adding plugins to kibana.json * Adding cases to required plugin * [Cases] Refactoring authorization (#97483) * Refactoring authorization * Wrapping auth calls in helper for try catch * Reverting name change * Hardcoding the saved object types * Switching ensure to owner array * [Cases] Add authorization to configuration & cases routes (#97228) * [Cases] Attachments RBAC (#97756) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Addressing PR comments * Reducing operations * [Cases] Add RBAC to remaining Cases APIs (#98762) * Starting rbac for comments * Adding authorization to rest of comment apis * Starting the comment rbac tests * Fixing some of the rbac tests * Adding some integration tests * Starting patch tests * Working tests for comments * Working tests * Fixing some tests * Fixing type issues from pulling in master * Fixing connector tests that only work in trial license * Attempting to fix cypress * Mock return of array for configure * Fixing cypress test * Cleaning up * Working case update tests * Addressing PR comments * Reducing operations * Working rbac push case tests * Starting stats apis * Working status tests * User action tests and fixing migration errors * Fixing type errors * including error in message * Addressing pr feedback * Fixing some type errors * [Cases] Add space only tests (#99409) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * [Cases] Add security only tests (#99679) * Starting spaces tests * Finishing space only tests * Refactoring createCaseWithConnector * Fixing spelling * Addressing PR feedback and creating alert tests * Fixing mocks * Starting security only tests * Adding remainder security only tests * Using helper objects * Fixing type error for null space * Renaming utility variables * Refactoring users and roles for security only tests * Adding sub feature * [Cases] Cleaning up the services and TODOs (#99723) * Cleaning up the service intialization * Fixing type errors * Adding comments for the api * Working test for cases client * Fix type error * Adding generated docs * Adding more docs and cleaning up types * Cleaning up readme * More clean up and links * Changing some file names * Renaming docs * Integration tests for cases privs and fixes (#100038) * [Cases] RBAC on UI (#99478) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Fixing case ids by alert id route call * [Cases] Fixing UI feature permissions and adding UI tests (#100074) * Integration tests for cases privs and fixes * Fixing ui cases permissions and adding tests * Adding test for collection failure and fixing jest * Renaming variables * Fixing type error * Adding some comments * Validate cases features * Fix new schema * Adding owner param for the status stats * Fix get case status tests * Adjusting permissions text and fixing status * Address PR feedback * Adding top level feature back * Fixing feature privileges * Renaming * Removing uneeded else * Fixing tests and adding cases merge tests * [Cases][Security Solution] Basic license security solution API tests (#100925) * Cleaning up the fixture plugins * Adding basic feature test * renaming to unsecuredSavedObjectsClient (#101215) * [Cases] RBAC Refactoring audit logging (#100952) * Refactoring audit logging * Adding unit tests for authorization classes * Addressing feedback and adding util tests * return undefined on empty array * fixing eslint * conditional rendering the recently created cases * Remove unnecessary Array.from * Cleaning up overview page for permissions * Fixing log message for attachments * hiding add to cases button * Disable the Cases app from the global nav * Hide the add to cases button from detections * Fixing merge * Making progress on removing icons * Hding edit icons on detail view * Trying to get connector error msg tests working * Removing test * Disable error callouts * Fixing spacing and removing cases tab one no read * Adding read only badge * Cleaning up and adding badge * Wrapping in use effect * Default toasting permissions errors * Removing actions icon on comments * Addressing feedback * Fixing type Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
3da2ac8927
commit
c5e8df02c1
67 changed files with 1119 additions and 497 deletions
|
@ -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(
|
||||
<TestProviders>
|
||||
<AddComment {...{ ...addCommentProps, disabled: true }} />
|
||||
<AddComment {...addCommentProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -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(
|
||||
<TestProviders>
|
||||
<AddComment {...{ ...addCommentProps, userCanCrud: false }} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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<AddCommentRefObject>();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<AddComment {...{ ...addCommentProps }} ref={ref} />
|
||||
<AddComment {...addCommentProps} ref={ref} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -143,7 +153,7 @@ describe('AddComment ', () => {
|
|||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CasesTimelineIntegrationProvider timelineIntegration={mockTimelineIntegration}>
|
||||
<AddComment {...{ ...addCommentProps }} />
|
||||
<AddComment {...addCommentProps} />
|
||||
</CasesTimelineIntegrationProvider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
|
|
@ -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<AddCommentRefObject, AddCommentProps>(
|
||||
(
|
||||
{ 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 (
|
||||
<span id="add-comment-permLink">
|
||||
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}
|
||||
<Form form={form}>
|
||||
<UseField
|
||||
path={fieldName}
|
||||
component={MarkdownEditorForm}
|
||||
componentProps={{
|
||||
idAria: 'caseComment',
|
||||
isDisabled: isLoading,
|
||||
dataTestSubj: 'add-comment',
|
||||
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
|
||||
bottomRightContent: (
|
||||
<EuiButton
|
||||
data-test-subj="submit-comment"
|
||||
iconType="plusInCircle"
|
||||
isDisabled={isLoading || disabled}
|
||||
isLoading={isLoading}
|
||||
onClick={onSubmit}
|
||||
size="s"
|
||||
>
|
||||
{i18n.ADD_COMMENT}
|
||||
</EuiButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InsertTimeline fieldName="comment" />
|
||||
</Form>
|
||||
{userCanCrud && (
|
||||
<Form form={form}>
|
||||
<UseField
|
||||
path={fieldName}
|
||||
component={MarkdownEditorForm}
|
||||
componentProps={{
|
||||
idAria: 'caseComment',
|
||||
isDisabled: isLoading,
|
||||
dataTestSubj: 'add-comment',
|
||||
placeholder: i18n.ADD_COMMENT_HELP_TEXT,
|
||||
bottomRightContent: (
|
||||
<EuiButton
|
||||
data-test-subj="submit-comment"
|
||||
iconType="plusInCircle"
|
||||
isDisabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={onSubmit}
|
||||
size="s"
|
||||
>
|
||||
{i18n.ADD_COMMENT}
|
||||
</EuiButton>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InsertTimeline fieldName="comment" />
|
||||
</Form>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -52,17 +52,27 @@ export const CasesTableHeader: FunctionComponent<Props> = ({
|
|||
wrap={true}
|
||||
data-test-subj="all-cases-header"
|
||||
>
|
||||
<FlexItemDivider grow={false}>
|
||||
<Count refresh={refresh} />
|
||||
</FlexItemDivider>
|
||||
<EuiFlexItem grow={false}>
|
||||
<NavButtons
|
||||
actionsErrors={actionsErrors}
|
||||
configureCasesNavigation={configureCasesNavigation}
|
||||
createCaseNavigation={createCaseNavigation}
|
||||
userCanCrud={userCanCrud}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{userCanCrud ? (
|
||||
<>
|
||||
<FlexItemDivider grow={false}>
|
||||
<Count refresh={refresh} />
|
||||
</FlexItemDivider>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<NavButtons
|
||||
actionsErrors={actionsErrors}
|
||||
configureCasesNavigation={configureCasesNavigation}
|
||||
createCaseNavigation={createCaseNavigation}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</>
|
||||
) : (
|
||||
// doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons
|
||||
// to the right
|
||||
<EuiFlexItem>
|
||||
<Count refresh={refresh} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</CaseHeaderPage>
|
||||
);
|
||||
|
|
|
@ -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<Props> = ({
|
|||
actionsErrors,
|
||||
configureCasesNavigation,
|
||||
createCaseNavigation,
|
||||
userCanCrud,
|
||||
}) => (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ConfigureCaseButton
|
||||
configureCasesNavigation={configureCasesNavigation}
|
||||
label={i18n.CONFIGURE_CASES_BUTTON}
|
||||
isDisabled={!isEmpty(actionsErrors) || !userCanCrud}
|
||||
isDisabled={!isEmpty(actionsErrors)}
|
||||
showToolTip={!isEmpty(actionsErrors)}
|
||||
msgTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].description : <></>}
|
||||
titleTooltip={!isEmpty(actionsErrors) ? actionsErrors[0].title : ''}
|
||||
|
@ -41,7 +39,6 @@ export const NavButtons: FunctionComponent<Props> = ({
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<LinkButton
|
||||
isDisabled={!userCanCrud}
|
||||
fill
|
||||
onClick={createCaseNavigation.onClick}
|
||||
href={createCaseNavigation.href}
|
||||
|
|
|
@ -121,19 +121,21 @@ export const CasesTable: FunctionComponent<CasesTableProps> = ({
|
|||
<EuiEmptyPrompt
|
||||
title={<h3>{i18n.NO_CASES}</h3>}
|
||||
titleSize="xs"
|
||||
body={i18n.NO_CASES_BODY}
|
||||
body={userCanCrud ? i18n.NO_CASES_BODY : i18n.NO_CASES_BODY_READ_ONLY}
|
||||
actions={
|
||||
<LinkButton
|
||||
isDisabled={!userCanCrud}
|
||||
fill
|
||||
size="s"
|
||||
onClick={goToCreateCase}
|
||||
href={createCaseNavigation.href}
|
||||
iconType="plusInCircle"
|
||||
data-test-subj="cases-table-add-case"
|
||||
>
|
||||
{i18n.ADD_NEW_CASE}
|
||||
</LinkButton>
|
||||
userCanCrud && (
|
||||
<LinkButton
|
||||
isDisabled={!userCanCrud}
|
||||
fill
|
||||
size="s"
|
||||
onClick={goToCreateCase}
|
||||
href={createCaseNavigation.href}
|
||||
iconType="plusInCircle"
|
||||
data-test-subj="cases-table-add-case"
|
||||
>
|
||||
{i18n.ADD_NEW_CASE}
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -19,14 +19,12 @@ interface CaseViewActions {
|
|||
allCasesNavigation: CasesNavigation;
|
||||
caseData: Case;
|
||||
currentExternalIncident: CaseService | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ActionsComponent: React.FC<CaseViewActions> = ({
|
||||
allCasesNavigation,
|
||||
caseData,
|
||||
currentExternalIncident,
|
||||
disabled = false,
|
||||
}) => {
|
||||
// Delete case
|
||||
const {
|
||||
|
@ -39,7 +37,6 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
|
|||
const propertyActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
disabled,
|
||||
iconType: 'trash',
|
||||
label: i18n.DELETE_CASE(),
|
||||
onClick: handleToggleModal,
|
||||
|
@ -54,7 +51,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
|
|||
]
|
||||
: []),
|
||||
],
|
||||
[disabled, handleToggleModal, currentExternalIncident]
|
||||
[handleToggleModal, currentExternalIncident]
|
||||
);
|
||||
|
||||
if (isDeleted) {
|
||||
|
|
|
@ -26,6 +26,7 @@ describe('CaseActionBar', () => {
|
|||
onRefresh,
|
||||
onUpdateField,
|
||||
currentExternalIncident: null,
|
||||
userCanCrud: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
|
@ -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<CaseActionBarProps> = ({
|
|||
allCasesNavigation,
|
||||
caseData,
|
||||
currentExternalIncident,
|
||||
disabled = false,
|
||||
disableAlerting,
|
||||
userCanCrud,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
onUpdateField,
|
||||
|
@ -87,7 +87,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
<EuiDescriptionListDescription>
|
||||
<StatusContextMenu
|
||||
currentStatus={caseData.status}
|
||||
disabled={disabled || isLoading}
|
||||
disabled={!userCanCrud || isLoading}
|
||||
onStatusChanged={onStatusChanged}
|
||||
/>
|
||||
</EuiDescriptionListDescription>
|
||||
|
@ -108,7 +108,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiDescriptionList compressed>
|
||||
<EuiFlexGroup gutterSize="l" alignItems="center">
|
||||
{!disableAlerting && (
|
||||
{userCanCrud && !disableAlerting && (
|
||||
<EuiFlexItem>
|
||||
<EuiDescriptionListTitle>
|
||||
<EuiFlexGroup component="span" alignItems="center" gutterSize="xs">
|
||||
|
@ -122,7 +122,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
<SyncAlertsSwitch
|
||||
disabled={disabled || isLoading}
|
||||
disabled={isLoading}
|
||||
isSynced={caseData.settings.syncAlerts}
|
||||
onSwitchChange={onSyncAlertsChanged}
|
||||
/>
|
||||
|
@ -134,14 +134,15 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
{i18n.CASE_REFRESH}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
|
||||
<Actions
|
||||
allCasesNavigation={allCasesNavigation}
|
||||
caseData={caseData}
|
||||
currentExternalIncident={currentExternalIncident}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{userCanCrud && (
|
||||
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
|
||||
<Actions
|
||||
allCasesNavigation={allCasesNavigation}
|
||||
caseData={caseData}
|
||||
currentExternalIncident={currentExternalIncident}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionList>
|
||||
</EuiFlexItem>
|
||||
|
|
|
@ -230,7 +230,9 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
[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<CaseComponentProps>(
|
|||
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<CaseComponentProps>(
|
|||
useFetchAlertData={useFetchAlertData}
|
||||
userCanCrud={userCanCrud}
|
||||
/>
|
||||
{(caseData.type !== CaseType.collection || hasDataToPush) && (
|
||||
{(caseData.type !== CaseType.collection || hasDataToPush) && userCanCrud && (
|
||||
<>
|
||||
<MyEuiHorizontalRule
|
||||
margin="s"
|
||||
|
@ -418,7 +420,6 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
<StatusActionButton
|
||||
status={caseData.status}
|
||||
onStatusChanged={changeStatus}
|
||||
disabled={!userCanCrud}
|
||||
isLoading={isLoading && updateKey === 'status'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
@ -450,16 +451,15 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
/>
|
||||
<TagList
|
||||
data-test-subj="case-view-tag-list"
|
||||
disabled={!userCanCrud}
|
||||
userCanCrud={userCanCrud}
|
||||
tags={caseData.tags}
|
||||
onSubmit={onSubmitTags}
|
||||
isLoading={isLoading && updateKey === 'tags'}
|
||||
owner={[caseData.owner]}
|
||||
/>
|
||||
<EditConnector
|
||||
caseFields={caseData.connector.fields}
|
||||
connectors={connectors}
|
||||
disabled={!userCanCrud}
|
||||
userCanCrud={userCanCrud}
|
||||
hideConnectorServiceNowSir={
|
||||
subCaseId != null || caseData.type === CaseType.collection
|
||||
}
|
||||
|
@ -467,6 +467,7 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
onSubmit={onSubmitConnector}
|
||||
selectedConnector={caseData.connector.id}
|
||||
userActions={caseUserActions}
|
||||
permissionsError={permissionsError}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
|
|
@ -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<typeof useKibana>;
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<EditConnector {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
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(
|
||||
<TestProviders>
|
||||
<EditConnector {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<EditConnector {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
<EuiText>
|
||||
<MyFlexGroup alignItems="center" gutterSize="xs" justifyContent="spaceBetween">
|
||||
|
@ -210,11 +224,10 @@ export const EditConnector = React.memo(
|
|||
<h4>{i18n.CONNECTORS}</h4>
|
||||
</EuiFlexItem>
|
||||
{isLoading && <EuiLoadingSpinner data-test-subj="connector-loading" />}
|
||||
{!isLoading && !editConnector && (
|
||||
{!isLoading && !editConnector && userCanCrud && (
|
||||
<EuiFlexItem data-test-subj="connector-edit" grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="connector-edit-button"
|
||||
isDisabled={disabled}
|
||||
aria-label={i18n.EDIT_CONNECTOR_ARIA}
|
||||
iconType={'pencil'}
|
||||
onClick={onEditClick}
|
||||
|
@ -235,7 +248,7 @@ export const EditConnector = React.memo(
|
|||
connectors,
|
||||
dataTestSubj: 'caseConnectors',
|
||||
defaultValue: selectedConnector,
|
||||
disabled,
|
||||
disabled: !userCanCrud,
|
||||
hideConnectorServiceNowSir,
|
||||
idAria: 'caseConnectors',
|
||||
isEdit: editConnector,
|
||||
|
@ -248,13 +261,21 @@ export const EditConnector = React.memo(
|
|||
</Form>
|
||||
</DisappearingFlexItem>
|
||||
<EuiFlexItem data-test-subj="edit-connector-fields-form-flex-item">
|
||||
{(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined.
|
||||
!(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted.
|
||||
!editConnector && (
|
||||
<EuiText size="s">
|
||||
{!editConnector && permissionsError ? (
|
||||
<EuiText data-test-subj="edit-connector-permissions-error-msg" size="s">
|
||||
<span>{permissionsError}</span>
|
||||
</EuiText>
|
||||
) : (
|
||||
// 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 && (
|
||||
<EuiText data-test-subj="edit-connector-no-connectors-msg" size="s">
|
||||
<span>{i18n.NO_CONNECTOR}</span>
|
||||
</EuiText>
|
||||
)}
|
||||
)
|
||||
)}
|
||||
<ConnectorFieldsForm
|
||||
connector={currentConnector}
|
||||
fields={fields}
|
||||
|
|
|
@ -32,6 +32,7 @@ const defaultProps = {
|
|||
href: 'create-details-href',
|
||||
onClick: jest.fn(),
|
||||
},
|
||||
hasWritePermissions: true,
|
||||
maxCasesToShow: 10,
|
||||
owner: [SECURITY_SOLUTION_OWNER],
|
||||
};
|
||||
|
|
|
@ -21,6 +21,7 @@ export interface RecentCasesProps extends Owner {
|
|||
allCasesNavigation: CasesNavigation;
|
||||
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
|
||||
createCaseNavigation: CasesNavigation;
|
||||
hasWritePermissions: boolean;
|
||||
maxCasesToShow: number;
|
||||
}
|
||||
|
||||
|
@ -29,6 +30,7 @@ const RecentCasesComponent = ({
|
|||
caseDetailsNavigation,
|
||||
createCaseNavigation,
|
||||
maxCasesToShow,
|
||||
hasWritePermissions,
|
||||
}: Omit<RecentCasesProps, 'owner'>) => {
|
||||
const currentUser = useCurrentUser();
|
||||
const [recentCasesFilterBy, setRecentCasesFilterBy] = useState<RecentCasesFilterMode>(
|
||||
|
@ -77,6 +79,7 @@ const RecentCasesComponent = ({
|
|||
createCaseNavigation={createCaseNavigation}
|
||||
filterOptions={recentCasesFilterOptions}
|
||||
maxCasesToShow={maxCasesToShow}
|
||||
hasWritePermissions={hasWritePermissions}
|
||||
/>
|
||||
<EuiHorizontalRule margin="s" />
|
||||
<EuiText size="xs">
|
||||
|
|
|
@ -16,11 +16,22 @@ describe('RecentCases', () => {
|
|||
const createCaseHref = '/create';
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<NoCases createCaseHref={createCaseHref} />
|
||||
<NoCases createCaseHref={createCaseHref} hasWritePermissions />
|
||||
</TestProviders>
|
||||
);
|
||||
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(
|
||||
<TestProviders>
|
||||
<NoCases createCaseHref={createCaseHref} hasWritePermissions={false} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find(`[data-test-subj="no-cases-create-case"]`).exists()).toBeFalsy();
|
||||
expect(wrapper.find(`[data-test-subj="no-cases-readonly"]`).exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,16 +10,26 @@ import React from 'react';
|
|||
import { EuiLink } from '@elastic/eui';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
const NoCasesComponent = ({ createCaseHref }: { createCaseHref: string }) => (
|
||||
<>
|
||||
<span>{i18n.NO_CASES}</span>
|
||||
<EuiLink
|
||||
data-test-subj="no-cases-create-case"
|
||||
href={createCaseHref}
|
||||
>{` ${i18n.START_A_NEW_CASE}`}</EuiLink>
|
||||
{'!'}
|
||||
</>
|
||||
);
|
||||
const NoCasesComponent = ({
|
||||
createCaseHref,
|
||||
hasWritePermissions,
|
||||
}: {
|
||||
createCaseHref: string;
|
||||
hasWritePermissions: boolean;
|
||||
}) => {
|
||||
return hasWritePermissions ? (
|
||||
<>
|
||||
<span>{i18n.NO_CASES}</span>
|
||||
<EuiLink
|
||||
data-test-subj="no-cases-create-case"
|
||||
href={createCaseHref}
|
||||
>{` ${i18n.START_A_NEW_CASE}`}</EuiLink>
|
||||
{'!'}
|
||||
</>
|
||||
) : (
|
||||
<span data-test-subj="no-cases-readonly">{i18n.NO_CASES_READ_ONLY}</span>
|
||||
);
|
||||
};
|
||||
|
||||
NoCasesComponent.displayName = 'NoCasesComponent';
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ export interface RecentCasesProps {
|
|||
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
|
||||
createCaseNavigation: CasesNavigation;
|
||||
maxCasesToShow: number;
|
||||
hasWritePermissions: boolean;
|
||||
}
|
||||
|
||||
const usePrevious = (value: Partial<FilterOptions>) => {
|
||||
|
@ -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 ? (
|
||||
<LoadingPlaceholders lines={2} placeholders={3} />
|
||||
) : !isLoadingCases && data.cases.length === 0 ? (
|
||||
<NoCases createCaseHref={createCaseNavigation.href} />
|
||||
<NoCases createCaseHref={createCaseNavigation.href} hasWritePermissions={hasWritePermissions} />
|
||||
) : (
|
||||
<>
|
||||
{data.cases.map((c, i) => (
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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<Props> = ({
|
||||
status,
|
||||
onStatusChanged,
|
||||
disabled,
|
||||
isLoading,
|
||||
}) => {
|
||||
const StatusActionButtonComponent: React.FC<Props> = ({ status, onStatusChanged, isLoading }) => {
|
||||
const indexOfCurrentStatus = useMemo(
|
||||
() => caseStatuses.findIndex((caseStatus) => caseStatus === status),
|
||||
[status]
|
||||
|
@ -41,7 +35,6 @@ const StatusActionButtonComponent: React.FC<Props> = ({
|
|||
<EuiButton
|
||||
data-test-subj="case-view-status-action-button"
|
||||
iconType={statuses[caseStatuses[nextStatusIndex]].icon}
|
||||
isDisabled={disabled}
|
||||
isLoading={isLoading}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
|
|
@ -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(
|
||||
<Status disabled={true} type={CaseStatuses.open} withArrow={true} onClick={onClick} />
|
||||
);
|
||||
|
||||
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 () => {
|
||||
|
|
|
@ -29,18 +29,18 @@ const StatusComponent: React.FC<Props> = ({
|
|||
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 (
|
||||
<EuiBadge
|
||||
{...props}
|
||||
iconOnClick={onClick}
|
||||
iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA}
|
||||
data-test-subj={`status-badge-${type}`}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
{type === StatusAll ? allCaseStatus[StatusAll].label : statuses[type].label}
|
||||
</EuiBadge>
|
||||
|
|
|
@ -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(
|
||||
<TestProviders>
|
||||
<TagList {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
|||
<h4>{i18n.TAGS}</h4>
|
||||
</EuiFlexItem>
|
||||
{isLoading && <EuiLoadingSpinner data-test-subj="tag-list-loading" />}
|
||||
{!isLoading && (
|
||||
{!isLoading && userCanCrud && (
|
||||
<EuiFlexItem data-test-subj="tag-list-edit" grow={false}>
|
||||
<EuiButtonIcon
|
||||
data-test-subj="tag-list-edit-button"
|
||||
isDisabled={disabled}
|
||||
aria-label={i18n.EDIT_TAGS_ARIA}
|
||||
iconType={'pencil'}
|
||||
onClick={setIsEditTags.bind(null, true)}
|
||||
|
|
|
@ -268,4 +268,157 @@ describe('usePushToService', () => {
|
|||
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, ReturnUsePushToService>(
|
||||
() => usePushToService(noWriteProps),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() => usePushToService(noWriteProps),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() =>
|
||||
usePushToService({
|
||||
...noWriteProps,
|
||||
connectors: [],
|
||||
connector: {
|
||||
id: 'none',
|
||||
name: 'none',
|
||||
type: ConnectorTypes.none,
|
||||
fields: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() =>
|
||||
usePushToService({
|
||||
...noWriteProps,
|
||||
connector: {
|
||||
id: 'none',
|
||||
name: 'none',
|
||||
type: ConnectorTypes.none,
|
||||
fields: null,
|
||||
},
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() =>
|
||||
usePushToService({
|
||||
...noWriteProps,
|
||||
connector: {
|
||||
id: 'not-exist',
|
||||
name: 'not-exist',
|
||||
type: ConnectorTypes.none,
|
||||
fields: null,
|
||||
},
|
||||
isValidConnector: false,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() =>
|
||||
usePushToService({
|
||||
...noWriteProps,
|
||||
connectors: [],
|
||||
connector: {
|
||||
id: 'not-exist',
|
||||
name: 'not-exist',
|
||||
type: ConnectorTypes.none,
|
||||
fields: null,
|
||||
},
|
||||
isValidConnector: false,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
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, ReturnUsePushToService>(
|
||||
() =>
|
||||
usePushToService({
|
||||
...noWriteProps,
|
||||
caseStatus: CaseStatuses.closed,
|
||||
}),
|
||||
{
|
||||
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current.pushCallouts).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
() => (
|
||||
|
|
|
@ -241,7 +241,7 @@ export const UserActionTree = React.memo(
|
|||
() => (
|
||||
<AddComment
|
||||
caseId={caseId}
|
||||
disabled={!userCanCrud}
|
||||
userCanCrud={userCanCrud}
|
||||
ref={addCommentRef}
|
||||
onCommentPosted={handleUpdate}
|
||||
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)}
|
||||
|
@ -288,10 +288,10 @@ export const UserActionTree = React.memo(
|
|||
id={DESCRIPTION_ID}
|
||||
editLabel={i18n.EDIT_DESCRIPTION}
|
||||
quoteLabel={i18n.QUOTE}
|
||||
disabled={!userCanCrud}
|
||||
isLoading={isLoadingDescription}
|
||||
onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
|
||||
onQuote={handleManageQuote.bind(null, caseData.description)}
|
||||
userCanCrud={userCanCrud}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
@ -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: (
|
||||
<UserActionUsername username={currentUser?.username} fullName={currentUser?.fullName} />
|
||||
),
|
||||
'data-test-subj': 'add-comment',
|
||||
timelineIcon: (
|
||||
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
|
||||
),
|
||||
className: 'isEdit',
|
||||
children: MarkdownNewComment,
|
||||
},
|
||||
];
|
||||
const bottomActions = userCanCrud
|
||||
? [
|
||||
{
|
||||
username: (
|
||||
<UserActionUsername
|
||||
username={currentUser?.username}
|
||||
fullName={currentUser?.fullName}
|
||||
/>
|
||||
),
|
||||
'data-test-subj': 'add-comment',
|
||||
timelineIcon: (
|
||||
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
|
||||
),
|
||||
className: 'isEdit',
|
||||
children: MarkdownNewComment,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const comments = [...userActions, ...bottomActions];
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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) => (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<UserActionCopyLink id={id} getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<UserActionPropertyActions
|
||||
id={id}
|
||||
editLabel={editLabel}
|
||||
quoteLabel={quoteLabel}
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
onEdit={onEdit}
|
||||
onQuote={onQuote}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{userCanCrud && (
|
||||
<EuiFlexItem>
|
||||
<UserActionPropertyActions
|
||||
id={id}
|
||||
editLabel={editLabel}
|
||||
quoteLabel={quoteLabel}
|
||||
isLoading={isLoading}
|
||||
onEdit={onEdit}
|
||||
onQuote={onQuote}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<ConnectorsState>({
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
21
x-pack/plugins/cases/public/mocks.ts
Normal file
21
x-pack/plugins/cases/public/mocks.ts
Normal file
|
@ -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<CasesUiStart> => ({
|
||||
getAllCases: jest.fn(),
|
||||
getAllCasesSelectorModal: jest.fn(),
|
||||
getCaseView: jest.fn(),
|
||||
getConfigureCases: jest.fn(),
|
||||
getCreateCase: jest.fn(),
|
||||
getRecentCases: jest.fn(),
|
||||
});
|
||||
|
||||
export const casesPluginMock = {
|
||||
createStartContract,
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -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 && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: <>{i18n.PAGE_TITLE}</>,
|
||||
}}
|
||||
>
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
</ObservabilityPageTemplate>
|
||||
</>
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: <>{i18n.PAGE_TITLE}</>,
|
||||
}}
|
||||
>
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
</ObservabilityPageTemplate>
|
||||
) : (
|
||||
<CaseFeatureNoPermissions />
|
||||
);
|
||||
|
|
|
@ -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 && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView
|
||||
caseId={caseId}
|
||||
subCaseId={subCaseId}
|
||||
userCanCrud={userPermissions?.crud ?? false}
|
||||
/>
|
||||
</>
|
||||
<CaseView caseId={caseId} subCaseId={subCaseId} userCanCrud={userPermissions?.crud ?? false} />
|
||||
) : null;
|
||||
});
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<ObservabilityPageTemplate
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import * as i18n from '../../components/app/cases/translations';
|
||||
|
@ -40,10 +40,12 @@ export const CreateCasePage = React.memo(() => {
|
|||
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 (
|
||||
<ObservabilityPageTemplate
|
||||
|
|
|
@ -70,6 +70,9 @@ export enum SecurityPageName {
|
|||
administration = 'administration',
|
||||
}
|
||||
|
||||
/**
|
||||
* The ID of the cases plugin
|
||||
*/
|
||||
export const CASES_APP_ID = `${APP_ID}:${SecurityPageName.case}`;
|
||||
|
||||
export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`;
|
||||
|
|
|
@ -44,7 +44,7 @@ describe('Alerts timeline', () => {
|
|||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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', () => {
|
|||
</TestProviders>
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -208,19 +208,21 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ActionIconItem>
|
||||
<EuiPopover
|
||||
id="attachAlertToCasePanel"
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
repositionOnScroll
|
||||
>
|
||||
<EuiContextMenuPanel items={items} />
|
||||
</EuiPopover>
|
||||
</ActionIconItem>
|
||||
{userCanCrud && (
|
||||
<ActionIconItem>
|
||||
<EuiPopover
|
||||
id="attachAlertToCasePanel"
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
repositionOnScroll
|
||||
>
|
||||
<EuiContextMenuPanel items={items} />
|
||||
</EuiPopover>
|
||||
</ActionIconItem>
|
||||
)}
|
||||
{isCreateCaseFlyoutOpen && (
|
||||
<CreateCaseFlyout
|
||||
afterCaseCreated={attachAlertToCase}
|
||||
|
|
|
@ -12,7 +12,6 @@ import { useGetUserCasesPermissions } from '../../common/lib/kibana';
|
|||
import { SpyRoute } from '../../common/utils/route/spy_routes';
|
||||
import { AllCases } from '../components/all_cases';
|
||||
|
||||
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
import { CaseFeatureNoPermissions } from './feature_no_permissions';
|
||||
import { SecurityPageName } from '../../app/types';
|
||||
|
||||
|
@ -22,12 +21,6 @@ export const CasesPage = React.memo(() => {
|
|||
return userPermissions == null || userPermissions?.read ? (
|
||||
<>
|
||||
<WrapperPage>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
</WrapperPage>
|
||||
<SpyRoute pageName={SecurityPageName.case} />
|
||||
|
|
|
@ -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 ? (
|
||||
<>
|
||||
<WrapperPage noPadding>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView
|
||||
caseId={caseId}
|
||||
subCaseId={subCaseId}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { SecurityPageName } from '../../app/types';
|
||||
|
@ -37,10 +37,13 @@ const ConfigureCasesPageComponent: React.FC = () => {
|
|||
[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};
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
@ -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<typeof useKibana>;
|
||||
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(
|
||||
<Router>
|
||||
<TestProviders>
|
||||
<Case />
|
||||
</TestProviders>
|
||||
</Router>
|
||||
);
|
||||
|
||||
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(
|
||||
<Router>
|
||||
<TestProviders>
|
||||
<Case />
|
||||
</TestProviders>
|
||||
</Router>
|
||||
);
|
||||
|
||||
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(
|
||||
<Router>
|
||||
<TestProviders>
|
||||
<Case />
|
||||
</TestProviders>
|
||||
</Router>
|
||||
);
|
||||
|
||||
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(
|
||||
<Router>
|
||||
<TestProviders>
|
||||
<Case />
|
||||
</TestProviders>
|
||||
</Router>
|
||||
);
|
||||
|
||||
expect(mockedSetBadge).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -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 = () => (
|
||||
<Switch>
|
||||
<Route path={createCasePagePath}>
|
||||
<CreateCasePage />
|
||||
</Route>
|
||||
<Route path={configureCasesPagePath}>
|
||||
<ConfigureCasesPage />
|
||||
</Route>
|
||||
<Route exact path={subCaseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={caseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={subCaseDetailsPagePath}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route path={caseDetailsPagePath}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route strict exact path={casesPagePath}>
|
||||
<CasesPage />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
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 (
|
||||
<Switch>
|
||||
<Route path={createCasePagePath}>
|
||||
<CreateCasePage />
|
||||
</Route>
|
||||
<Route path={configureCasesPagePath}>
|
||||
<ConfigureCasesPage />
|
||||
</Route>
|
||||
<Route exact path={subCaseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={caseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={subCaseDetailsPagePath}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route path={caseDetailsPagePath}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route strict exact path={casesPagePath}>
|
||||
<CasesPage />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export const Case = React.memo(CaseContainerComponent);
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
<TestProviders>
|
||||
<HeaderGlobal />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<HeaderGlobal />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="navigation-case"]`).exists()).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -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<string>([
|
||||
...(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 (
|
||||
<Wrapper ref={ref} $isFixed={isFixed}>
|
||||
<WrapperContent $globalFullScreen={globalFullScreen ?? timelineFullScreen}>
|
||||
|
@ -109,14 +121,7 @@ export const HeaderGlobal = React.memo(
|
|||
</FlexItem>
|
||||
|
||||
<FlexItem component="nav">
|
||||
<SiemNavigation
|
||||
display="condensed"
|
||||
navTabs={
|
||||
hideDetectionEngine
|
||||
? pickBy((_, key) => key !== SecurityPageName.detections, navTabs)
|
||||
: navTabs
|
||||
}
|
||||
/>
|
||||
<SiemNavigation display="condensed" navTabs={tabsToDisplay} />
|
||||
</FlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FlexItem>
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
|
|
|
@ -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<typeof useKibana>;
|
||||
|
||||
describe('Sidebar', () => {
|
||||
let casesMock: jest.Mocked<CasesUiStart>;
|
||||
|
||||
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<typeof useKibana>);
|
||||
});
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<Sidebar recentTimelinesFilterBy={'favorites'} setRecentTimelinesFilterBy={() => {}} />
|
||||
</TestProviders>
|
||||
)
|
||||
);
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<Sidebar recentTimelinesFilterBy={'favorites'} setRecentTimelinesFilterBy={() => {}} />
|
||||
</TestProviders>
|
||||
)
|
||||
);
|
||||
|
||||
expect(casesMock.getRecentCases).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -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 (
|
||||
<SidebarFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem grow={false}>
|
||||
<RecentCases />
|
||||
</EuiFlexItem>
|
||||
{hasCasesReadPermissions && (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RecentCases />
|
||||
</EuiFlexItem>
|
||||
|
||||
<Spacer />
|
||||
<Spacer />
|
||||
</>
|
||||
)}
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<SidebarHeader title={i18n.RECENT_TIMELINES}>{recentTimelinesFilters}</SidebarHeader>
|
||||
|
|
|
@ -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(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
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')
|
||||
|
||||
expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
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(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="attach-timeline-case-button"]').exists()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the data is loading', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
|
||||
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(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
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 a loading indicator for values', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
|
||||
expect.stringContaining('--')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response is null and timeline is blank', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, null]);
|
||||
describe('when the data is loading', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([true, mockUseTimelineKpiResponse]);
|
||||
});
|
||||
it('renders a loading indicator for values', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="siem-timeline-process-kpi"]').first().text()).toEqual(
|
||||
expect.stringContaining('--')
|
||||
);
|
||||
});
|
||||
});
|
||||
it('renders labels and the default empty string', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
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 response is null and timeline is blank', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, null]);
|
||||
});
|
||||
it('renders labels and the default empty string', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
describe('when the response contains numbers larger than one thousand', () => {
|
||||
beforeEach(() => {
|
||||
mockUseTimelineKpis.mockReturnValue([false, mockUseTimelineLargeKpiResponse]);
|
||||
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(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
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(
|
||||
<TestProviders>
|
||||
<FlyoutHeader timelineId={TimelineId.test} />
|
||||
</TestProviders>
|
||||
);
|
||||
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')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<FlyoutHeaderProps> = ({ timelineId }) => {
|
|||
filterQuery: combinedQueries?.filterQuery ?? '',
|
||||
});
|
||||
|
||||
const hasWritePermissions = useGetUserCasesPermissions()?.crud ?? false;
|
||||
|
||||
return (
|
||||
<StyledTimelineHeader alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
|
@ -350,9 +352,11 @@ const FlyoutHeaderComponent: React.FC<FlyoutHeaderProps> = ({ timelineId }) => {
|
|||
<EuiFlexItem grow={false}>
|
||||
<AddToFavoritesButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddToCaseButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
{hasWritePermissions && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<AddToCaseButton timelineId={timelineId} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</StyledTimelineHeader>
|
||||
|
|
|
@ -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<PluginSetup, PluginStart, SetupPlugins, StartPlugins> {
|
||||
private readonly logger: Logger;
|
||||
private readonly config: ConfigType;
|
||||
|
@ -305,7 +311,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
}),
|
||||
order: 1100,
|
||||
category: DEFAULT_APP_CATEGORIES.security,
|
||||
app: [...securitySubPlugins, 'kibana'],
|
||||
app: [...allSecuritySubPlugins, 'kibana'],
|
||||
catalogue: ['securitySolution'],
|
||||
management: {
|
||||
insightsAndAlerting: ['triggersActions'],
|
||||
|
@ -320,6 +326,9 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
groupType: 'mutually_exclusive',
|
||||
privileges: [
|
||||
{
|
||||
// if the user is granted access to the cases feature than the global nav will show the cases
|
||||
// sub plugin within the security solution navigation
|
||||
app: [casesSubPlugin],
|
||||
id: 'cases_all',
|
||||
includeIn: 'all',
|
||||
name: 'All',
|
||||
|
@ -335,6 +344,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
},
|
||||
},
|
||||
{
|
||||
app: [casesSubPlugin],
|
||||
id: 'cases_read',
|
||||
includeIn: 'read',
|
||||
name: 'Read',
|
||||
|
@ -356,7 +366,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
],
|
||||
privileges: {
|
||||
all: {
|
||||
app: [...securitySubPlugins, 'kibana'],
|
||||
app: [...securitySubPluginsNoCases, 'kibana'],
|
||||
catalogue: ['securitySolution'],
|
||||
api: ['securitySolution', 'lists-all', 'lists-read'],
|
||||
savedObject: {
|
||||
|
@ -377,7 +387,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
|
|||
ui: ['show', 'crud'],
|
||||
},
|
||||
read: {
|
||||
app: [...securitySubPlugins, 'kibana'],
|
||||
app: [...securitySubPluginsNoCases, 'kibana'],
|
||||
catalogue: ['securitySolution'],
|
||||
api: ['securitySolution', 'lists-read'],
|
||||
savedObject: {
|
||||
|
|
|
@ -138,11 +138,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
|
||||
it(`landing page shows disabled "Create new case" button`, async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue