From 528c29ec4a428ee75f4743df8e887127519af8e5 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 21:11:28 -0400 Subject: [PATCH 01/17] modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed --- x-pack/plugins/case/common/api/cases/case.ts | 16 +- .../plugins/case/common/api/cases/comment.ts | 2 + x-pack/plugins/case/common/api/cases/index.ts | 1 + .../case/common/api/cases/user_actions.ts | 54 +++++ x-pack/plugins/case/server/plugin.ts | 9 + .../routes/api/__fixtures__/mock_router.ts | 5 +- .../api/__fixtures__/mock_saved_objects.ts | 12 +- .../api/cases/comments/delete_all_comments.ts | 27 ++- .../api/cases/comments/delete_comment.ts | 44 ++-- .../routes/api/cases/comments/get_comment.ts | 11 - .../api/cases/comments/patch_comment.ts | 43 +++- .../routes/api/cases/comments/post_comment.ts | 36 +-- .../server/routes/api/cases/delete_cases.ts | 27 ++- .../server/routes/api/cases/find_cases.ts | 39 +++- .../case/server/routes/api/cases/helpers.ts | 14 +- .../server/routes/api/cases/patch_cases.ts | 19 +- .../case/server/routes/api/cases/post_case.ts | 21 +- .../server/routes/api/cases/pushed_case.ts | 144 ++++++++++++ .../user_actions/get_all_user_actions.ts | 44 ++++ .../plugins/case/server/routes/api/types.ts | 12 +- .../plugins/case/server/routes/api/utils.ts | 32 ++- .../case/server/saved_object_types/cases.ts | 36 +++ .../server/saved_object_types/comments.ts | 13 ++ .../case/server/saved_object_types/index.ts | 1 + .../server/saved_object_types/user_actions.ts | 44 ++++ x-pack/plugins/case/server/services/index.ts | 50 +++- .../server/services/user_actions/helpers.ts | 215 ++++++++++++++++++ .../server/services/user_actions/index.ts | 77 +++++++ 28 files changed, 943 insertions(+), 105 deletions(-) create mode 100644 x-pack/plugins/case/common/api/cases/user_actions.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/pushed_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/user_actions.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/helpers.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/index.ts diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 6f58e2702ec5b..3b9fd0c603123 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -20,14 +20,24 @@ const CaseBasicRt = rt.type({ title: rt.string, }); +const CasePushBasicRt = rt.type({ + at: rt.union([rt.string, rt.null]), + by: rt.union([UserRT, rt.null]), + connector_id: rt.union([rt.string, rt.null]), + connector_name: rt.union([rt.string, rt.null]), + external_id: rt.union([rt.string, rt.null]), + external_title: rt.union([rt.string, rt.null]), + external_url: rt.union([rt.string, rt.null]), +}); + export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ - comment_ids: rt.array(rt.string), closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, + pushed: rt.union([CasePushBasicRt, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -35,6 +45,8 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; +export const CasePushRequestRt = CasePushBasicRt; + export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: StatusRt, @@ -53,6 +65,7 @@ export const CaseResponseRt = rt.intersection([ CaseAttributesRt, rt.type({ id: rt.string, + totalComment: rt.number, version: rt.string, }), rt.partial({ @@ -85,3 +98,4 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; +export type CasePushRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index cebfa00425728..4549b1c31a7cf 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -17,6 +17,8 @@ export const CommentAttributesRt = rt.intersection([ rt.type({ created_at: rt.string, created_by: UserRT, + pushed_at: rt.union([rt.string, rt.null]), + pushed_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 5fbee98bc57ad..ffcd4d25eecf5 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -8,3 +8,4 @@ export * from './case'; export * from './configure'; export * from './comment'; export * from './status'; +export * from './user_actions'; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts new file mode 100644 index 0000000000000..910c7d39771b8 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +const UserActionFieldRt = rt.array( + rt.union([ + rt.literal('comment'), + rt.literal('description'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + ]) +); +const UserActionRt = rt.union([ + rt.literal('add'), + rt.literal('create'), + rt.literal('delete'), + rt.literal('update'), + rt.literal('push-to-service'), +]); + +// TO DO change state to status +const CaseUserActionBasicRT = rt.type({ + action_field: UserActionFieldRt, + action: UserActionRt, + action_at: rt.string, + action_by: UserRT, + new_value: rt.union([rt.string, rt.null]), + old_value: rt.union([rt.string, rt.null]), +}); + +const CaseUserActionResponseRT = rt.intersection([ + CaseUserActionBasicRT, + rt.type({ + case_id: rt.string, + comment_id: rt.union([rt.string, rt.null]), + }), +]); + +export const CaseUserActionAttributesRt = CaseUserActionBasicRT; + +export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRT); + +export type CaseUserActionAttributes = rt.TypeOf; +export type CaseUserActionsResponse = rt.TypeOf; + +export type UserAction = rt.TypeOf; +export type UserActionField = rt.TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1d6495c2d81f3..3ccb699b85306 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -12,12 +12,18 @@ import { SecurityPluginSetup } from '../../security/server'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; +<<<<<<< HEAD import { caseSavedObjectType, caseConfigureSavedObjectType, caseCommentSavedObjectType, } from './saved_object_types'; import { CaseConfigureService, CaseService } from './services'; +======= +import { caseSavedObjectType, caseCommentSavedObjectType } from './saved_object_types'; +import { CaseService } from './services'; +import { CaseUserActionService } from './services/user_actions'; +>>>>>>> modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map(config => config)); @@ -49,6 +55,7 @@ export class CasePlugin { const caseServicePlugin = new CaseService(this.log); const caseConfigureServicePlugin = new CaseConfigureService(this.log); + const userActionServicePlugin = new CaseUserActionService(this.log); this.log.debug( `Setting up Case Workflow with core contract [${Object.keys( @@ -60,11 +67,13 @@ export class CasePlugin { authentication: plugins.security.authc, }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await userActionServicePlugin.setup(); const router = core.http.createRouter(); initCaseApi({ caseConfigureService, caseService, + userActionService, router, }); } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index bc41ddbeff1f9..0945ded8d57c3 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -6,7 +6,7 @@ import { IRouter } from 'kibana/server'; import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService } from '../../../services'; +import { CaseService, CaseConfigureService, CaseUserActionService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; import { RouteDeps } from '../types'; @@ -22,16 +22,19 @@ export const createRoute = async ( const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); + const userActionServicePlugin = new CaseUserActionService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await userActionServicePlugin.setup(); api({ caseConfigureService, caseService, router, + userActionService, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 5aa8b93f17b08..d3b7051b0defc 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -14,7 +14,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -22,6 +21,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', + pushed: null, title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], @@ -42,7 +42,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -50,6 +49,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', + pushed: null, title: 'Damaging Data Destruction Detected', status: 'open', tags: ['Data Destruction'], @@ -70,7 +70,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -78,6 +77,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', + pushed: null, title: 'Another bad one', status: 'open', tags: ['LOLBins'], @@ -147,6 +147,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', @@ -175,6 +177,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', @@ -204,6 +208,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 00d06bfdd2677..8f0e616ceacbb 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { +export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments', @@ -21,6 +23,8 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; + const deletedBy = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ client: context.core.savedObjects.client, @@ -35,15 +39,18 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { ) ); - const updateCase = { - comment_ids: [], - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: comments.saved_objects.map(comment => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: deletedBy, + caseId: request.params.case_id, + commentId: comment.id, + fields: ['comment'], + }) + ), }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 85c4701f82e1d..8587d24a85102 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -6,10 +6,13 @@ import Boom from 'boom'; import { schema } from '@kbn/config-schema'; + +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { +export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments/{comment_id}', @@ -23,14 +26,22 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ + const deletedBy = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + const myComment = await caseService.getComment({ client: context.core.savedObjects.client, - caseId: request.params.case_id, + commentId: request.params.comment_id, }); - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` ); } @@ -39,17 +50,18 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { commentId: request.params.comment_id, }); - const updateCase = { - comment_ids: myCase.attributes.comment_ids.filter( - cId => cId !== request.params.comment_id - ), - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: deletedBy, + caseId: request.params.case_id, + commentId: request.params.comment_id, + fields: ['comment'], + }), + ], }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 06619abae8487..24f44a5f5129b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -5,7 +5,6 @@ */ import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { CommentResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; @@ -25,16 +24,6 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); - - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { - throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` - ); - } const comment = await caseService.getComment({ client, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index c14a94e84e51c..ded5a8eefc03a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -11,11 +11,12 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; - +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; -export function initPatchCommentApi({ caseService, router }: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases/{case_id}/comments', @@ -28,27 +29,28 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCase = await caseService.getCase({ + const myComment = await caseService.getComment({ client: context.core.savedObjects.client, - caseId: request.params.case_id, + commentId: query.id, }); - if (!myCase.attributes.comment_ids.includes(query.id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${query.id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${query.id} does not exist in ${request.params.case_id}).` ); } - const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: query.id, - }); - if (query.version !== myComment.version) { throw Boom.conflict( 'This case has been updated. Please refresh before saving additional updates.' @@ -56,18 +58,35 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { } const updatedBy = await caseService.getUser({ request, response }); + const updatedDate = new Date().toISOString(); const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, updatedAttributes: { comment: query.comment, - updated_at: new Date().toISOString(), + updated_at: updatedDate, updated_by: { email, full_name, username }, }, version: query.version, }); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: updatedBy, + caseId: request.params.case_id, + commentId: updatedComment.id, + fields: ['comment'], + newValue: query.comment, + oldValue: myComment.attributes.comment, + }), + ], + }); + return response.ok({ body: CommentResponseRt.encode( flattenCommentSavedObject({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 9e82a8ffaaec7..4c5405a1aef5e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -12,6 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { escapeHatch, transformNewComment, @@ -20,7 +21,7 @@ import { } from '../../utils'; import { RouteDeps } from '../../types'; -export function initPostCommentApi({ caseService, router }: RouteDeps) { +export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases/{case_id}/comments', @@ -33,21 +34,17 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - }); - const createdBy = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, + client, attributes: transformNewComment({ createdDate, ...query, @@ -57,21 +54,24 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { { type: CASE_SAVED_OBJECT, name: `associated-${CASE_SAVED_OBJECT}`, - id: myCase.id, + id: request.params.case_id, }, ], }); - const updateCase = { - comment_ids: [...myCase.attributes.comment_ids, newComment.id], - }; - - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: createdBy, + caseId: request.params.case_id, + commentId: newComment.id, + fields: ['comment'], + newValue: query.comment, + }), + ], }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 559a477a83a6c..ea97122bd322c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -export function initDeleteCasesApi({ caseService, router }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases', @@ -20,10 +22,11 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; await Promise.all( request.query.ids.map(id => caseService.deleteCase({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -31,7 +34,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { const comments = await Promise.all( request.query.ids.map(id => caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -43,7 +46,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { Promise.all( c.saved_objects.map(({ id }) => caseService.deleteComment({ - client: context.core.savedObjects.client, + client, commentId: id, }) ) @@ -51,6 +54,22 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { ) ); } + const deletedBy = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map(id => + buildCaseUserActionItem({ + action: 'create', + actionAt: deleteDate, + actionBy: deletedBy, + caseId: id, + fields: ['comment', 'description', 'status', 'tags', 'title'], + }) + ), + }); + return response.ok({ body: 'true' }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 76a1992c64270..e7b2044f2badf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -13,7 +13,7 @@ import { identity } from 'fp-ts/lib/function'; import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps } from '../types'; +import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => @@ -97,9 +97,44 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), ]); + + const totalCommentsFindByCases = await Promise.all( + cases.saved_objects.map(c => + caseService.getAllCaseComments({ + client, + caseId: c.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const totalCommentsByCases = totalCommentsFindByCases.reduce( + (acc, itemFind) => { + if (itemFind.saved_objects.length > 0) { + const caseId = + itemFind.saved_objects[0].references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? + null; + if (caseId != null) { + return [...acc, { caseId, totalComments: itemFind.total }]; + } + } + return [...acc]; + }, + [] + ); + return response.ok({ body: CasesFindResponseRt.encode( - transformCases(cases, openCases.total ?? 0, closesCases.total ?? 0) + transformCases( + cases, + openCases.total ?? 0, + closesCases.total ?? 0, + totalCommentsByCases + ) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 3bf46cadc83c8..537568310a06f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -8,6 +8,13 @@ import { difference, get } from 'lodash'; import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +export const isTwoArraysDifference = (origVal: unknown, updatedVal: unknown) => + origVal != null && + updatedVal != null && + Array.isArray(updatedVal) && + Array.isArray(origVal) && + difference(origVal, updatedVal).length !== 0; + export const getCaseToUpdate = ( currentCase: CaseAttributes, queryCase: CasePatchRequest @@ -15,12 +22,7 @@ export const getCaseToUpdate = ( Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); - if ( - currentValue != null && - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { + if (isTwoArraysDifference(value, currentValue)) { return { ...acc, [key]: value, diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 4aa0d8daf5b34..73bd38fd04b88 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,8 +18,9 @@ import { import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; import { getCaseToUpdate } from './helpers'; +import { buildCaseUserActions } from '../../../services/user_actions/helpers'; -export function initPatchCasesApi({ caseService, router }: RouteDeps) { +export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases', @@ -29,12 +30,13 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CasesPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCases = await caseService.getCases({ - client: context.core.savedObjects.client, + client, caseIds: query.cases.map(q => q.id), }); let nonExistingCases: CasePatchRequest[] = []; @@ -76,7 +78,7 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { const { email, full_name, username } = updatedBy; const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - client: context.core.savedObjects.client, + client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; let closedInfo = {}; @@ -116,6 +118,17 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { references: myCase.references, }); }); + + await userActionService.postUserActions({ + client, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { full_name, username }, + }), + }); + return response.ok({ body: CasesResponseRt.encode(returnUpdatedCase), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 9e854c3178e1e..0c881f1b11c87 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -12,9 +12,10 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPostCaseApi({ caseService, router }: RouteDeps) { +export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases', @@ -24,6 +25,7 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CaseRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) @@ -32,13 +34,28 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { const createdBy = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, + client, attributes: transformNewCase({ createdDate, newCase: query, ...createdBy, }), }); + + await userActionService.postUserActions({ + client, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: createdBy, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title'], + newValue: JSON.stringify(query), + }), + ], + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts b/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts new file mode 100644 index 0000000000000..4297be93899da --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; + +import { CasePushRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { RouteDeps } from '../types'; + +export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/_pushed', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + query: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const caseId = request.params.case_id; + const query = pipe( + CasePushRequestRt.decode(request.query), + fold(throwErrors(Boom.badRequest), identity) + ); + const pushedBy = await caseService.getUser({ request, response }); + const pushedDate = new Date().toISOString(); + + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + const totalCommentsFindByCases = await caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }); + + const comments = await caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: totalCommentsFindByCases.total, + }, + }); + + const pushed = { + at: pushedDate, + by: pushedBy, + ...query, + }; + + const [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client, + caseId, + updatedAttributes: { + pushed, + updated_at: pushedDate, + updated_by: pushedBy, + }, + version: myCase.version, + }), + caseService.patchComments({ + client, + comments: comments.saved_objects.map(comment => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: pushedBy, + updated_at: pushedDate, + updated_by: pushedBy, + }, + version: comment.version, + })), + }), + ]); + + await userActionService.postUserActions({ + client, + actions: [ + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: pushedBy, + caseId, + fields: [], + newValue: JSON.stringify(pushed), + }), + ], + }); + + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject( + { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments.saved_objects.map(origComment => { + const updatedComment = updatedComments.saved_objects.find( + c => c.id === origComment.id + ); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }) + ) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..c5ac87f56d3de --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { CaseUserActionsResponseRt } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/user_actions', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const userActions = await userActionService.getUserActions({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + return response.ok({ + body: CaseUserActionsResponseRt.encode( + userActions.saved_objects.map(ua => ({ + ...ua.attributes, + case_id: ua.references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find(r => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 7af3e7b70d96f..e532a7b618b5c 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,11 +5,16 @@ */ import { IRouter } from 'src/core/server'; -import { CaseConfigureServiceSetup, CaseServiceSetup } from '../../services'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; export interface RouteDeps { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; router: IRouter; } @@ -18,3 +23,8 @@ export enum SortFieldCase { createdAt = 'created_at', status = 'status', } + +export interface TotalCommentByCase { + caseId: string; + totalComments: number; +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 19dbb024d1e0b..27553b25f89fd 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -22,7 +22,8 @@ import { CommentsResponse, CommentAttributes, } from '../../../common/api'; -import { SortFieldCase } from './types'; + +import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ createdDate, @@ -37,11 +38,11 @@ export const transformNewCase = ({ newCase: CaseRequest; username: string; }): CaseAttributes => ({ - closed_at: newCase.status === 'closed' ? createdDate : null, - closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, - comment_ids: [], + closed_at: null, + closed_by: null, created_at: createdDate, created_by: { email, full_name, username }, + pushed: null, updated_at: null, updated_by: null, ...newCase, @@ -64,6 +65,8 @@ export const transformNewComment = ({ comment, created_at: createdDate, created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, updated_at: null, updated_by: null, }); @@ -81,30 +84,41 @@ export function wrapError(error: any): CustomHttpResponseOptions export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, - countClosedCases: number + countClosedCases: number, + totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'] + savedObjects: SavedObjectsFindResponse['saved_objects'], + totalCommentByCase: TotalCommentByCase[] ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; + return [ + ...acc, + flattenCaseSavedObject( + savedObject, + [], + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 + ), + ]; }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject, - comments: Array> = [] + comments: Array> = [], + totalComment: number = 0 ): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), + totalComment: 0, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 8eab040b9ca9c..b8d91eadddc4c 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,6 +14,7 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { +<<<<<<< HEAD closed_at: { type: 'date', }, @@ -33,6 +34,8 @@ export const caseSavedObjectType: SavedObjectsType = { comment_ids: { type: 'keyword', }, +======= +>>>>>>> modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed created_at: { type: 'date', }, @@ -52,6 +55,38 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + pushed: { + properties: { + at: { + type: 'date', + }, + by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + connector_id: { + type: 'keyword', + }, + connector_name: { + type: 'keyword', + }, + external_id: { + type: 'keyword', + }, + external_title: { + type: 'text', + }, + external_url: { + type: 'text', + }, + }, + }, title: { type: 'keyword', }, @@ -61,6 +96,7 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, + updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index f52da886e7611..8776dd39b11fa 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -33,6 +33,19 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 978b3d35ee5c6..0e4b9fa3e2eee 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -7,3 +7,4 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; +export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts new file mode 100644 index 0000000000000..7e823e4550d36 --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; + +export const caseUserActionSavedObjectType: SavedObjectsType = { + name: CASE_USER_ACTION_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + action_field: { + type: 'keyword', + }, + action: { + type: 'keyword', + }, + action_at: { + type: 'date', + }, + action_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + new_value: { + type: 'text', + }, + old_value: { + type: 'text', + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 4bbffddf63251..01d55e993ad8a 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -24,11 +24,17 @@ import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; +export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; -interface ClientArgs { +export interface ClientArgs { client: SavedObjectsClientContract; } +interface PushedArgs { + pushed_at: string; + pushed_by: User; +} + interface GetCaseArgs extends ClientArgs { caseId: string; } @@ -37,7 +43,7 @@ interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface GetCommentsArgs extends GetCaseArgs { +interface FindCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; } @@ -47,6 +53,7 @@ interface FindCasesArgs extends ClientArgs { interface GetCommentArgs extends ClientArgs { commentId: string; } + interface PostCaseArgs extends ClientArgs { attributes: CaseAttributes; } @@ -58,7 +65,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -68,10 +75,20 @@ interface PatchCasesArgs extends ClientArgs { } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } +interface PatchComment { + commentId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchComments extends ClientArgs { + comments: PatchComment[]; +} + interface GetUserArgs { request: KibanaRequest; response: KibanaResponseFactory; @@ -84,7 +101,7 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: GetCommentsArgs): Promise>; + getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; @@ -96,6 +113,7 @@ export interface CaseServiceSetup { patchCase(args: PatchCaseArgs): Promise>; patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; + patchComments(args: PatchComments): Promise>; } export class CaseService { @@ -157,7 +175,7 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { + getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ @@ -261,5 +279,25 @@ export class CaseService { throw error; } }, + patchComments: async ({ client, comments }: PatchComments) => { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map(c => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map(c => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map(c => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + }, }); } diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts new file mode 100644 index 0000000000000..3e56cf54ad2c2 --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; +import { get } from 'lodash'; + +import { + CaseUserActionAttributes, + UserAction, + UserActionField, + CaseAttributes, + User, +} from '../../../common/api'; +import { isTwoArraysDifference } from '../../routes/api/cases/helpers'; +import { UserActionItem } from '.'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; + +export const transformNewUserAction = ({ + actionField, + action, + actionAt, + full_name, + newValue = null, + oldValue = null, + username, +}: { + actionField: UserActionField; + action: UserAction; + actionAt: string; + full_name?: string; + newValue?: string | null; + oldValue?: string | null; + username: string; +}): CaseUserActionAttributes => ({ + action_field: actionField, + action, + action_at: actionAt, + action_by: { full_name, username }, + new_value: newValue, + old_value: oldValue, +}); + +interface BuildCaseUserAction { + action: UserAction; + actionAt: string; + actionBy: User; + caseId: string; + fields: UserActionField | unknown[]; + newValue?: string | unknown; + oldValue?: string | unknown; +} + +interface BuildCommentUserActionItem extends BuildCaseUserAction { + commentId: string; +} + +export const buildCommentUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + commentId, + fields, + newValue, + oldValue, +}: BuildCommentUserActionItem): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + { + type: CASE_COMMENT_SAVED_OBJECT, + name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, + id: commentId, + }, + ], +}); + +export const buildCaseUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + fields, + newValue, + oldValue, +}: BuildCaseUserAction): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], +}); + +interface CompareArray { + addedItems: string[]; + deletedItems: string[]; +} +const compareArray = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArray => { + const result: CompareArray = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach(origVal => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach(updatedVal => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const buildCaseUserActions = ({ + actionDate, + actionBy, + originalCases, + updatedCases, +}: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => + updatedCases.reduce((acc, updatedItem) => { + const originalItem = originalCases.find(oItem => oItem.id === updatedItem.id); + if (originalItem != null) { + let userActions: UserActionItem[] = []; + const updatedFields = Object.keys(updatedItem.attributes); + updatedFields.forEach(field => { + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); + if (isTwoArraysDifference(origValue, updatedValue)) { + const arrayDiff = compareArray({ + originalValue: origValue as string[], + updatedValue: updatedValue as string[], + }); + if (arrayDiff.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: arrayDiff.addedItems.join(', '), + }), + ]; + } + if (arrayDiff.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: arrayDiff.deletedItems.join(', '), + }), + ]; + } + } else if (origValue !== updatedValue) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'update', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: updatedValue, + oldValue: origValue, + }), + ]; + } + }); + return [...acc, ...userActions]; + } + return acc; + }, []); diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts new file mode 100644 index 0000000000000..d14fa13dd8582 --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SavedObjectsFindResponse, + Logger, + SavedObjectsBulkResponse, + SavedObjectReference, +} from 'kibana/server'; + +import { CaseUserActionAttributes } from '../../../common/api'; +import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { ClientArgs } from '..'; + +interface GetCaseUserActionArgs extends ClientArgs { + caseId: string; +} + +export interface UserActionItem { + attributes: CaseUserActionAttributes; + references: SavedObjectReference[]; +} + +interface PostCaseUserActionArgs extends ClientArgs { + actions: UserActionItem[]; +} + +export interface CaseUserActionServiceSetup { + getUserActions( + args: GetCaseUserActionArgs + ): Promise>; + postUserActions( + args: PostCaseUserActionArgs + ): Promise>; +} + +export class CaseUserActionService { + constructor(private readonly log: Logger) {} + public setup = async (): Promise => ({ + getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => { + try { + const caseUserActionInfo = await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + fields: [], + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + }); + return await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: caseUserActionInfo.total, + sortField: 'action_date', + sortOrder: 'asc', + }); + } catch (error) { + this.log.debug(`Error on GET case user action: ${error}`); + throw error; + } + }, + postUserActions: async ({ client, actions }: PostCaseUserActionArgs) => { + try { + this.log.debug(`Attempting to POST a new case user action`); + return await client.bulkCreate( + actions.map(action => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) + ); + } catch (error) { + this.log.debug(`Error on POST a new case user action: ${error}`); + throw error; + } + }, + }); +} From 9c764a45c67ebbf6e07609ba161e7b9e56eca30b Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 21:24:40 -0400 Subject: [PATCH 02/17] fix rebase --- x-pack/plugins/case/server/plugin.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 3ccb699b85306..a6a459373b0ed 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -12,18 +12,13 @@ import { SecurityPluginSetup } from '../../security/server'; import { ConfigType } from './config'; import { initCaseApi } from './routes/api'; -<<<<<<< HEAD import { caseSavedObjectType, caseConfigureSavedObjectType, caseCommentSavedObjectType, + caseUserActionSavedObjectType, } from './saved_object_types'; -import { CaseConfigureService, CaseService } from './services'; -======= -import { caseSavedObjectType, caseCommentSavedObjectType } from './saved_object_types'; -import { CaseService } from './services'; -import { CaseUserActionService } from './services/user_actions'; ->>>>>>> modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed +import { CaseConfigureService, CaseService, CaseUserActionService } from './services'; function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map(config => config)); @@ -52,6 +47,7 @@ export class CasePlugin { core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseUserActionSavedObjectType); const caseServicePlugin = new CaseService(this.log); const caseConfigureServicePlugin = new CaseConfigureService(this.log); From e493f6bb745107dc26f10ec975ed17fe9fc91808 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 21:38:18 -0400 Subject: [PATCH 03/17] add connector name in case configuration saved object --- .../public/containers/case/configure/use_configure.tsx | 9 +++++++-- .../pages/case/components/configure_cases/index.tsx | 8 ++++++-- x-pack/plugins/case/common/api/cases/configure.ts | 1 + x-pack/plugins/case/server/saved_object_types/cases.ts | 6 ------ .../plugins/case/server/saved_object_types/configure.ts | 3 +++ 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 22ac54093d1dc..b81be645fa3a4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -13,6 +13,7 @@ import { ClosureType } from './types'; interface PersistCaseConfigure { connectorId: string; + connectorName: string; closureType: ClosureType; } @@ -74,7 +75,7 @@ export const useCaseConfigure = ({ }, []); const persistCaseConfigure = useCallback( - async ({ connectorId, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -83,7 +84,11 @@ export const useCaseConfigure = ({ const res = version.length === 0 ? await postCaseConfigure( - { connector_id: connectorId, closure_type: closureType }, + { + connector_id: connectorId, + connector_name: connectorName, + closure_type: closureType, + }, abortCtrl.signal ) : await patchCaseConfigure( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index c8ef6e32595d0..8a6d8a40e560a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => { // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { setActionBarVisible(false); - persistCaseConfigure({ connectorId, closureType }); + persistCaseConfigure({ + connectorId, + connectorName: connectors.find(c => c.id === connectorId)?.name ?? '', + closureType, + }); }, - [connectorId, closureType, mapping] + [connectorId, connectors, closureType, mapping] ); const onChangeConnector = useCallback((newConnectorId: string) => { diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index e0489ed7270fa..9b210c2aa05ad 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -73,6 +73,7 @@ const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-b const CasesConfigureBasicRt = rt.type({ connector_id: rt.string, + connector_name: rt.string, closure_type: ClosureTypeRT, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index b8d91eadddc4c..c445374c37adc 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -14,7 +14,6 @@ export const caseSavedObjectType: SavedObjectsType = { namespaceAgnostic: false, mappings: { properties: { -<<<<<<< HEAD closed_at: { type: 'date', }, @@ -31,11 +30,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - comment_ids: { - type: 'keyword', - }, -======= ->>>>>>> modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed created_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index 8ea6f6bba7d4f..a4e0607fe2844 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -30,6 +30,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { connector_id: { type: 'keyword', }, + connector_name: { + type: 'keyword', + }, closure_type: { type: 'keyword', }, From 33fc7d51c30f102e9c4d8bc53805f4086ad3975c Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 21:52:02 -0400 Subject: [PATCH 04/17] fix total comment in all cases --- .../plugins/siem/public/containers/case/types.ts | 2 +- .../siem/public/containers/case/use_get_case.tsx | 2 +- .../case/components/all_cases/__mock__/index.tsx | 14 +++++++------- .../pages/case/components/all_cases/columns.tsx | 7 ++++--- .../case/components/case_view/__mock__/index.tsx | 3 ++- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 44519031e91cb..1cfdc0c34f0cc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -21,13 +21,13 @@ export interface Case { closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - commentIds: string[]; createdAt: string; createdBy: ElasticUser; description: string; status: string; tags: string[]; title: string; + totalComment: number; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index b70195e2c126f..7127ad7932a0b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,7 +53,6 @@ const initialData: Case = { closedBy: null, createdAt: '', comments: [], - commentIds: [], createdBy: { username: '', }, @@ -61,6 +60,7 @@ const initialData: Case = { status: '', tags: [], title: '', + totalComment: 0, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 48fbb4e74c407..7bb97a75f4515 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -18,12 +18,12 @@ export const useGetCasesMockState: UseGetCasesState = { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', status: 'open', tags: ['defacement'], title: 'Another horrible breach', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -34,12 +34,12 @@ export const useGetCasesMockState: UseGetCasesState = { id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -50,12 +50,12 @@ export const useGetCasesMockState: UseGetCasesState = { id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -66,14 +66,14 @@ export const useGetCasesMockState: UseGetCasesState = { id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: '2020-02-13T19:44:13.328Z', - updatedBy: { username: 'elastic' }, + totalComment: 0, + updatedAt: null, + updatedBy: null, version: 'WzQ3LDFd', }, { @@ -82,12 +82,12 @@ export const useGetCasesMockState: UseGetCasesState = { id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', status: 'open', tags: ['phishing'], title: 'Uh oh', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index b9e1113c486ad..32a29483e9c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -35,6 +35,7 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); + export const getCasesColumns = ( actions: Array>, filterStatus: string @@ -108,11 +109,11 @@ export const getCasesColumns = ( }, { align: 'right', - field: 'commentIds', + field: 'totalComment', name: i18n.COMMENTS, sortable: true, - render: (comments: Case['commentIds']) => - renderStringField(`${comments.length}`, `case-table-column-commentCount`), + render: (totalComment: Case['totalComment']) => + renderStringField(`${totalComment}`, `case-table-column-commentCount`), }, filterStatus === 'open' ? { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index e11441eac3a9d..be937411c495c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -13,7 +13,6 @@ export const caseProps: CaseProps = { closedAt: null, closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ { comment: 'Solve this fast!', @@ -37,6 +36,7 @@ export const caseProps: CaseProps = { status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', + totalComment: 1, updatedAt: '2020-02-19T15:02:57.995Z', updatedBy: { username: 'elastic', @@ -44,6 +44,7 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; + export const caseClosedProps: CaseProps = { ...caseProps, initialData: { From 687765597345a50db8b08c15e6d14f4d9170af51 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 22:17:48 -0400 Subject: [PATCH 05/17] totalComment bug on the API --- x-pack/plugins/case/server/routes/api/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 27553b25f89fd..c026541787dca 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -118,7 +118,7 @@ export const flattenCaseSavedObject = ( id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), - totalComment: 0, + totalComment, ...savedObject.attributes, }); From 34c59154cd3f2a93a54f66f253e2fdc4e7f71f89 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Fri, 20 Mar 2020 00:00:09 -0400 Subject: [PATCH 06/17] integrate user action API with UI --- .../siem/public/components/url_state/types.ts | 16 +- .../siem/public/containers/case/api.ts | 18 +++ .../public/containers/case/configure/types.ts | 1 + .../siem/public/containers/case/types.ts | 25 ++- .../case/use_get_case_user_actions.tsx | 89 +++++++++++ .../containers/case/use_update_case.tsx | 4 +- .../containers/case/use_update_comment.tsx | 12 +- .../siem/public/containers/case/utils.ts | 8 + .../case/components/add_comment/index.tsx | 129 ++++++++------- .../pages/case/components/case_view/index.tsx | 51 ++++-- .../case/components/case_view/translations.ts | 31 +++- .../components/user_action_tree/helpers.tsx | 57 +++++++ .../components/user_action_tree/index.tsx | 151 ++++++++++++++---- .../user_action_tree/user_action_item.tsx | 42 ++++- .../user_action_tree/user_action_title.tsx | 130 ++++++++++++--- .../plugins/siem/public/pages/case/index.tsx | 4 + .../siem/public/pages/case/translations.ts | 24 ++- x-pack/plugins/case/common/api/cases/case.ts | 23 ++- .../case/common/api/cases/user_actions.ts | 4 + .../server/routes/api/cases/pushed_case.ts | 2 +- .../user_actions/get_all_user_actions.ts | 1 + .../plugins/case/server/routes/api/index.ts | 16 +- .../server/services/user_actions/helpers.ts | 83 +++++----- .../server/services/user_actions/index.ts | 2 +- 24 files changed, 720 insertions(+), 203 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index 2cb1b0c96ad79..c6f49d8a0e49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - timeline: [CONSTANTS.timeline, CONSTANTS.timerange], - case: [], + timeline: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], + case: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], }; export type LocationTypes = diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5ba1f010e0d52..d95d8d6bbc93d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -13,6 +13,7 @@ import { CommentRequest, CommentResponse, User, + CaseUserActionsResponse, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { @@ -23,16 +24,19 @@ import { Comment, FetchCasesProps, SortFieldCase, + CaseUserActions, } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, + convertArrayToCamelCase, decodeCaseResponse, decodeCasesResponse, decodeCasesFindResponse, decodeCasesStatusResponse, decodeCommentResponse, + decodeCaseUserActionsResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -71,6 +75,20 @@ export const getReporters = async (signal: AbortSignal): Promise => { return response ?? []; }; +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/user_actions`, + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index fc7aaa3643d77..d69c23fe02ec9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -26,6 +26,7 @@ export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; connectorId: string; + connectorName: string; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 1cfdc0c34f0cc..04d0090084937 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,18 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User } from '../../../../../../plugins/case/common/api'; +import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api'; export interface Comment { id: string; createdAt: string; createdBy: ElasticUser; comment: string; + pushedAt: string | null; + pushedBy: string | null; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; } +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} +export interface CasePush { + at: string; + by: string; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} export interface Case { id: string; closedAt: string | null; @@ -24,6 +46,7 @@ export interface Case { createdAt: string; createdBy: ElasticUser; description: string; + pushed: CasePush; status: string; tags: string[]; title: string; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx new file mode 100644 index 0000000000000..0e3e5fe59d54f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash/fp'; +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCaseUserActions } from './api'; +import * as i18n from './translations'; +import { CaseUserActions } from './types'; + +interface CaseUserActionsState { + caseUserActions: CaseUserActions[]; + isLoading: boolean; + isError: boolean; +} + +const initialData: CaseUserActionsState = { + caseUserActions: [], + isLoading: true, + isError: false, +}; + +interface UseGetCaseUserActions extends CaseUserActionsState { + fetchCaseUserActions: (caseId: string) => void; +} + +export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { + const [caseUserActionsState, setCaseUserActionsState] = useState( + initialData + ); + + const [, dispatchToaster] = useStateToaster(); + + const fetchCaseUserActions = useCallback( + (thisCaseId: string) => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + try { + const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); + if (!didCancel) { + // Attention Future developer + // We are removing the first item because it will always the creation of the case + // and we do not want it to simplify our life + setCaseUserActionsState({ + caseUserActions: !isEmpty(response) ? response.slice(1) : [], + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCaseUserActionsState({ + caseUserActions: [], + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, + [caseUserActionsState] + ); + + useEffect(() => { + if (!isEmpty(caseId)) { + fetchCaseUserActions(caseId); + } + }, [caseId]); + return { ...caseUserActionsState, fetchCaseUserActions }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 987620469901b..5cf01bd0be195 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,6 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; + fetchCaseUserActions: (caseId: string) => void; } type Action = @@ -75,7 +76,7 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase const [, dispatchToaster] = useStateToaster(); const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue }: UpdateByKey) => { + async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: updateKey }); @@ -85,6 +86,7 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { + fetchCaseUserActions(caseId); dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index a40a1100ca735..c1b2bfde30126 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd } }; +interface UpdateComment { + caseId: string; + commentId: string; + commentUpdate: string; + fetchUserActions: () => void; +} + interface UseUpdateComment extends CommentUpdateState { - updateComment: (caseId: string, commentId: string, commentUpdate: string) => void; + updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; addPostedComment: Dispatch; } @@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [, dispatchToaster] = useStateToaster(); const dispatchUpdateComment = useCallback( - async (caseId: string, commentId: string, commentUpdate: string) => { + async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: commentId }); @@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { currentComment.version ); if (!cancel) { + fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8f24d5a435240..63a1d3f3620f6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -23,6 +23,8 @@ import { CommentResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -86,3 +88,9 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) = CaseConfigureResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 0b3b0daaf4bbc..836595c7c45d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; + showLoading?: boolean; } -export const AddComment = React.memo(({ caseId, onCommentPosted }) => { - const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); - const { form } = useForm({ - defaultValue: initialCommentValue, - options: { stripEmptyFields: false }, - schema, - }); - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - form, - 'comment' - ); +export const AddComment = React.memo( + ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); + const { form } = useForm({ + defaultValue: initialCommentValue, + options: { stripEmptyFields: false }, + schema, + }); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( + form, + 'comment' + ); - useEffect(() => { - if (commentData !== null) { - onCommentPosted(commentData); - form.reset(); - resetCommentData(); - } - }, [commentData]); + useEffect(() => { + if (commentData !== null) { + onCommentPosted(commentData); + form.reset(); + resetCommentData(); + } + }, [commentData]); - const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); - if (isValid) { - await postComment(data); - } - }, [form]); + const onSubmit = useCallback(async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + if (onCommentSaving != null) { + onCommentSaving(); + } + await postComment(data); + } + }, [form]); - return ( - <> - {isLoading && } -
- - {i18n.ADD_COMMENT} - - ), - topRightContent: ( - - ), - }} - /> - - - ); -}); + return ( + <> + {isLoading && showLoading && } +
+ + {i18n.ADD_COMMENT} + + ), + topRightContent: ( + + ), + }} + /> + + + ); + } +); AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 0ac3adeb860ff..4c8c2de2dd463 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled, { css } from 'styled-components'; +import { Redirect } from 'react-router-dom'; -import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -24,6 +25,10 @@ import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; +import { SiemPageName } from '../../../home/types'; +import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; interface Props { caseId: string; @@ -45,6 +50,12 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; + const [initLoadingData, setInitLoadingData] = useState(true); + const { + caseUserActions, + isLoading: isLoadingUserActions, + fetchCaseUserActions, + } = useGetCaseUserActions(caseId); const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); // Update Fields @@ -55,6 +66,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'title', updateValue: titleUpdate, }); @@ -64,6 +76,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'description', updateValue: descriptionUpdate, }); @@ -72,6 +85,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'tags': const tagsUpdate = getTypedPayload(updateValue); updateCaseProperty({ + fetchCaseUserActions, updateKey: 'tags', updateValue: tagsUpdate, }); @@ -80,6 +94,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const statusUpdate = getTypedPayload(updateValue); if (caseData.status !== updateValue) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'status', updateValue: statusUpdate, }); @@ -88,7 +103,12 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [caseData.status] + [fetchCaseUserActions, updateCaseProperty, caseData.status] + ); + + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), + [onUpdateField] ); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); @@ -128,6 +148,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => }), [caseData.title] ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + return ( <> @@ -159,11 +186,17 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => - + {initLoadingData && } + {!initLoadingData && ( + + )} { + if (field === 'tags' && action.action === 'add') { + return ( + + + {i18n.ADDED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + + ); + } + if (field === 'tags' && action.action === 'delete') { + return ( + + + {i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + + ); + } else if (field === 'title' && action.action === 'update') { + return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"`; + } else if (field === 'description' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + } else if (field === 'status' && action.action === 'update') { + return `${ + action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() + } ${i18n.CASE}`; + } else if (field === 'comment' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } + return ''; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 6a3d319561353..81181d64b169d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,27 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import styled from 'styled-components'; + import * as i18n from '../case_view/translations'; -import { Case } from '../../../../containers/case/types'; +import { Case, CaseUserActions, Comment } from '../../../../containers/case/types'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; +import { AddComment } from '../add_comment'; +import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; -import { AddComment } from '../add_comment'; -import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; + caseUserActions: CaseUserActions[]; isLoadingDescription: boolean; + isLoadingUserActions: boolean; + fetchUserActions: () => void; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + const DescriptionId = 'description'; const NewId = 'newComment'; export const UserActionTree = React.memo( - ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + ({ + data: caseData, + caseUserActions, + fetchUserActions, + isLoadingDescription, + isLoadingUserActions, + onUpdateField, + }: UserActionTreeProps) => { + const handlerTimeoutId = useRef(0); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); @@ -45,11 +65,36 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - updateComment(caseData.id, id, content); + updateComment({ + caseId: caseData.id, + commentId: id, + commentUpdate: content, + fetchUserActions, + }); }, [handleManageMarkdownEditId, updateComment] ); + const handleOutlineComment = useCallback( + (id: string) => { + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId.current] + ); + + const handleUpdate = useCallback( + (comment: Comment) => { + addPostedComment(comment); + fetchUserActions(); + }, + [addPostedComment, fetchUserActions] + ); + const MarkdownDescription = useMemo( () => ( , - [caseData.id] + () => ( + + ), + [caseData.id, handleUpdate] ); return ( @@ -85,29 +137,68 @@ export const UserActionTree = React.memo( onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} userName={caseData.createdBy.username} /> - {comments.map(comment => ( - + {caseUserActions.map(action => { + if (action.commentId != null && action.action === 'create') { + const comment = comments.find(c => c.id === action.commentId); + if (comment != null) { + return ( + {i18n.ADDED_COMMENT}} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + + } + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + outlineComment={handleOutlineComment} + userName={comment.createdBy.username} + updatedAt={comment.updatedAt} + /> + ); } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - userName={comment.createdBy.username} - /> - ))} + } + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const labelTitle: string | JSX.Element = getLabelTitle(myField, action); + + return ( + {labelTitle}} + linkId={ + action.action === 'update' && action.commentId != null ? action.commentId : null + } + fullName={action.actionBy.fullName ?? action.actionBy.username} + outlineComment={handleOutlineComment} + userName={action.actionBy.username} + /> + ); + } + return null; + })} + {(isLoadingUserActions || isLoadingIds.includes(NewId)) && ( + + + + + + )} void; + markdown?: React.ReactNode; + onEdit?: (id: string) => void; userName: string; + updatedAt?: string | null; + outlineComment?: (id: string) => void; + idToOutline?: string | null; } const UserActionItemContainer = styled(EuiFlexGroup)` @@ -66,17 +70,32 @@ const UserActionItemContainer = styled(EuiFlexGroup)` `} `; +const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + ${({ theme, showoutline }) => + showoutline === 'true' + ? ` + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + ` + : ''} +`; + export const UserActionItem = ({ createdAt, id, + idToOutline, isEditable, isLoading, labelEditAction, labelTitle, + linkId, fullName, markdown, onEdit, + outlineComment, userName, + updatedAt, }: UserActionItemProps) => ( @@ -89,18 +108,25 @@ export const UserActionItem = ({ {isEditable && markdown} {!isEditable && ( - + } + linkId={linkId} userName={userName} + updatedAt={updatedAt} onEdit={onEdit} + outlineComment={outlineComment} /> {markdown} - + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 0ed081e8852f0..289eebae6c527 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import copy from 'copy-to-clipboard'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useCallback } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { - FormattedRelativePreferenceDate, - FormattedRelativePreferenceLabel, -} from '../../../../components/formatted_date'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import * as i18n from '../case_view/translations'; import { PropertyActions } from '../property_actions'; @@ -25,10 +28,13 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelEditAction: string; - labelTitle: string; + labelEditAction?: string; + labelTitle: JSX.Element; + linkId?: string | null; + updatedAt?: string | null; userName: string; - onEdit: (id: string) => void; + onEdit?: (id: string) => void; + outlineComment?: (id: string) => void; } export const UserActionTitle = ({ @@ -37,32 +43,104 @@ export const UserActionTitle = ({ isLoading, labelEditAction, labelTitle, + linkId, userName, + updatedAt, onEdit, + outlineComment, }: UserActionTitleProps) => { + const { detailName: caseId } = useParams(); + const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - return [ - { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - }, [id, onEdit]); + if (labelEditAction != null && onEdit != null) { + return [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ]; + } + return []; + }, [id, labelEditAction, onEdit]); + + const handleAnchorLink = useCallback(() => { + copy(`${window.location.origin}${window.location.pathname}#case/${caseId}/id${urlSearch}`, { + debug: true, + }); + }, [caseId, id, urlSearch]); + + const handleMoveToLink = useCallback(() => { + if (outlineComment != null && linkId != null) { + outlineComment(linkId); + } + const moveToTarget = document.getElementById(`${linkId}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } + }, [linkId, outlineComment]); + return ( - + -

- {userName} - {` ${labelTitle} `} - - -

+ + + {userName} + + {labelTitle} + + + + + + {updatedAt != null && ( + + + {'('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + + )} +
- {isLoading && } - {!isLoading && } + + {!isEmpty(linkId) && ( + + + + )} + + + + {propertyActions.length > 0 && ( + + {isLoading && } + {!isLoading && } + + )} +
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index 1bde9de1535b5..cecb38816781b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -15,6 +15,7 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = `/:pageName(${SiemPageName.case})`; const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -32,6 +33,9 @@ const CaseContainerComponent: React.FC = () => ( + + + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 341a34240fe49..db556c61c65e8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -86,8 +86,28 @@ export const PAGE_TITLE = i18n.translate('xpack.siem.case.pageTitle', { defaultMessage: 'Cases', }); -export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', { - defaultMessage: 'Create case', +export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { + defaultMessage: 'Closed case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const REOPENED_CASE = i18n.translate('xpack.siem.case.caseView.reopenedCase', { + defaultMessage: 'Reopened case', +}); + +export const CASE_NAME = i18n.translate('xpack.siem.case.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.siem.case.caseView.to', { + defaultMessage: 'to', }); export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 3b9fd0c603123..9fff630d4ff2b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -21,13 +21,11 @@ const CaseBasicRt = rt.type({ }); const CasePushBasicRt = rt.type({ - at: rt.union([rt.string, rt.null]), - by: rt.union([UserRT, rt.null]), - connector_id: rt.union([rt.string, rt.null]), - connector_name: rt.union([rt.string, rt.null]), - external_id: rt.union([rt.string, rt.null]), - external_title: rt.union([rt.string, rt.null]), - external_url: rt.union([rt.string, rt.null]), + connector_id: rt.string, + connector_name: rt.string, + external_id: rt.string, + external_title: rt.string, + external_url: rt.string, }); export const CaseAttributesRt = rt.intersection([ @@ -37,7 +35,16 @@ export const CaseAttributesRt = rt.intersection([ closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, - pushed: rt.union([CasePushBasicRt, rt.null]), + pushed: rt.union([ + rt.intersection([ + CasePushBasicRt, + rt.type({ + at: rt.string, + by: UserRT, + }), + ]), + rt.null, + ]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 910c7d39771b8..6bc74ea04e119 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -8,6 +8,9 @@ import * as rt from 'io-ts'; import { UserRT } from '../user'; +/* To the next developer, if you add/removed fields here + * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too + */ const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), @@ -38,6 +41,7 @@ const CaseUserActionBasicRT = rt.type({ const CaseUserActionResponseRT = rt.intersection([ CaseUserActionBasicRT, rt.type({ + action_id: rt.string, case_id: rt.string, comment_id: rt.union([rt.string, rt.null]), }), diff --git a/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts b/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts index 4297be93899da..23e6baa79c446 100644 --- a/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts @@ -16,7 +16,7 @@ import { CasePushRequestRt, CaseResponseRt, throwErrors } from '../../../../comm import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { +export function initPushedCaseUserActionApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases/{case_id}/_pushed', diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index c5ac87f56d3de..911ca9ced3643 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -31,6 +31,7 @@ export function initGetAllUserActionsApi({ userActionService, router }: RouteDep body: CaseUserActionsResponseRt.encode( userActions.saved_objects.map(ua => ({ ...ua.attributes, + action_id: ua.id, case_id: ua.references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? '', comment_id: ua.references.find(r => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, })) diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 60ee57a0efea7..1fe7d77a7c662 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -9,6 +9,11 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; +import { initPushedCaseUserActionApi } from './cases/pushed_case'; +import { initGetReportersApi } from './cases/reporters/get_reporters'; +import { initGetCasesStatusApi } from './cases/status/get_status'; +import { initGetTagsApi } from './cases/tags/get_tags'; +import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; @@ -18,18 +23,13 @@ import { initGetCommentApi } from './cases/comments/get_comment'; import { initPatchCommentApi } from './cases/comments/patch_comment'; import { initPostCommentApi } from './cases/comments/post_comment'; -import { initGetReportersApi } from './cases/reporters/get_reporters'; - -import { initGetCasesStatusApi } from './cases/status/get_status'; - -import { initGetTagsApi } from './cases/tags/get_tags'; - -import { RouteDeps } from './types'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { RouteDeps } from './types'; + export function initCaseApi(deps: RouteDeps) { // Cases initDeleteCasesApi(deps); @@ -37,6 +37,8 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); + initPushedCaseUserActionApi(deps); + initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index 3e56cf54ad2c2..a2fe22a396787 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -143,6 +143,13 @@ const compareArray = ({ return result; }; +const userActionFieldsAllowed: UserActionField = [ + 'comment', + 'description', + 'tags', + 'title', + 'status', +]; export const buildCaseUserActions = ({ actionDate, @@ -159,54 +166,56 @@ export const buildCaseUserActions = ({ const originalItem = originalCases.find(oItem => oItem.id === updatedItem.id); if (originalItem != null) { let userActions: UserActionItem[] = []; - const updatedFields = Object.keys(updatedItem.attributes); + const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; updatedFields.forEach(field => { - const origValue = get(originalItem, ['attributes', field]); - const updatedValue = get(updatedItem, ['attributes', field]); - if (isTwoArraysDifference(origValue, updatedValue)) { - const arrayDiff = compareArray({ - originalValue: origValue as string[], - updatedValue: updatedValue as string[], - }); - if (arrayDiff.addedItems.length > 0) { + if (userActionFieldsAllowed.includes(field)) { + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); + if (isTwoArraysDifference(origValue, updatedValue)) { + const arrayDiff = compareArray({ + originalValue: origValue as string[], + updatedValue: updatedValue as string[], + }); + if (arrayDiff.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: arrayDiff.addedItems.join(', '), + }), + ]; + } + if (arrayDiff.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: arrayDiff.deletedItems.join(', '), + }), + ]; + } + } else if (origValue !== updatedValue) { userActions = [ ...userActions, buildCaseUserActionItem({ - action: 'add', + action: 'update', actionAt: actionDate, actionBy, caseId: updatedItem.id, fields: [field], - newValue: arrayDiff.addedItems.join(', '), + newValue: updatedValue, + oldValue: origValue, }), ]; } - if (arrayDiff.deletedItems.length > 0) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'delete', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: arrayDiff.deletedItems.join(', '), - }), - ]; - } - } else if (origValue !== updatedValue) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'update', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: updatedValue, - oldValue: origValue, - }), - ]; } }); return [...acc, ...userActions]; diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts index d14fa13dd8582..0e9babf9d81af 100644 --- a/x-pack/plugins/case/server/services/user_actions/index.ts +++ b/x-pack/plugins/case/server/services/user_actions/index.ts @@ -54,7 +54,7 @@ export class CaseUserActionService { hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, page: 1, perPage: caseUserActionInfo.total, - sortField: 'action_date', + sortField: 'action_at', sortOrder: 'asc', }); } catch (error) { From 7fbce3d629b1c74e451881117a5d25a98c728fc0 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Fri, 20 Mar 2020 13:19:28 -0400 Subject: [PATCH 07/17] fix merged issue --- .../plugins/siem/public/containers/case/types.ts | 2 +- .../siem/public/containers/case/use_get_case.tsx | 1 + .../siem/public/containers/case/use_update_case.tsx | 6 ++++-- .../pages/case/components/all_cases/__mock__/index.tsx | 5 +++++ .../pages/case/components/case_view/__mock__/index.tsx | 3 +++ .../public/pages/case/components/case_view/index.tsx | 10 +--------- .../pages/case/components/user_action_tree/index.tsx | 1 - .../plugins/siem/public/pages/case/translations.ts | 10 ++++------ .../routes/api/__fixtures__/mock_saved_objects.ts | 4 ++-- .../case/server/routes/api/cases/patch_cases.ts | 2 +- .../case/server/saved_object_types/configure.ts | 6 ++++++ .../case/server/saved_object_types/user_actions.ts | 3 +++ .../case/server/services/user_actions/helpers.ts | 4 +++- 13 files changed, 34 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 04d0090084937..f4224487dfeda 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -46,7 +46,7 @@ export interface Case { createdAt: string; createdBy: ElasticUser; description: string; - pushed: CasePush; + pushed: CasePush | null; status: string; tags: string[]; title: string; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index 7127ad7932a0b..bbd4a84bccca5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -57,6 +57,7 @@ const initialData: Case = { username: '', }, description: '', + pushed: null, status: '', tags: [], title: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 5cf01bd0be195..dc14e0b18f7dc 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,7 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; - fetchCaseUserActions: (caseId: string) => void; + fetchCaseUserActions?: (caseId: string) => void; } type Action = @@ -86,7 +86,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { - fetchCaseUserActions(caseId); + if (fetchCaseUserActions != null) { + fetchCaseUserActions(caseId); + } dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 7bb97a75f4515..3ca36d2dd9632 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -20,6 +20,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', + pushed: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', @@ -36,6 +37,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', + pushed: null, status: 'open', tags: ['phishing'], title: 'Bad email', @@ -52,6 +54,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', + pushed: null, status: 'open', tags: ['phishing'], title: 'Bad email', @@ -68,6 +71,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', + pushed: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', @@ -84,6 +88,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', + pushed: null, status: 'open', tags: ['phishing'], title: 'Uh oh', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index be937411c495c..5eeaa46727242 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -23,6 +23,8 @@ export const caseProps: CaseProps = { username: 'smilovic', email: 'notmyrealemailfool@elastic.co', }, + pushedAt: null, + pushedBy: null, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { username: 'elastic', @@ -33,6 +35,7 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', + pushed: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 4c8c2de2dd463..33cb453bb6bf3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -6,8 +6,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import styled, { css } from 'styled-components'; -import { Redirect } from 'react-router-dom'; +import styled from 'styled-components'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; @@ -25,9 +24,6 @@ import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; -import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; -import { SiemPageName } from '../../../home/types'; -import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; interface Props { @@ -106,10 +102,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => [fetchCaseUserActions, updateCaseProperty, caseData.status] ); - const toggleStatusCase = useCallback( - e => onUpdateField('status', e.target.checked ? 'open' : 'closed'), - [onUpdateField] - ); const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 81181d64b169d..f1a0fda12d072 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -206,7 +206,6 @@ export const UserActionTree = React.memo( isLoading={isLoadingIds.includes(NewId)} fullName={currentUser != null ? currentUser.fullName : ''} markdown={MarkdownNewComment} - onEdit={handleManageMarkdownEditId.bind(null, NewId)} userName={currentUser != null ? currentUser.username : ''} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index db556c61c65e8..f91e623197b2f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -33,12 +33,6 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { defaultMessage: 'Reporter', @@ -86,6 +80,10 @@ export const PAGE_TITLE = i18n.translate('xpack.siem.case.pageTitle', { defaultMessage: 'Cases', }); +export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', { + defaultMessage: 'Create case', +}); + export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { defaultMessage: 'Closed case', }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index d3b7051b0defc..3ad8e0b23b936 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -102,7 +102,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -110,8 +109,9 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - title: 'Another bad one', + pushed: null, status: 'closed', + title: 'Another bad one', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 73bd38fd04b88..e9102f7916348 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -125,7 +125,7 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro originalCases: myCases.saved_objects, updatedCases: updatedCases.saved_objects, actionDate: updatedDt, - actionBy: { full_name, username }, + actionBy: { email, full_name, username }, }), }); diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index a4e0607fe2844..d66c38b6ea8ff 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -19,6 +19,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, created_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, @@ -41,6 +44,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, updated_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts index 7e823e4550d36..b61bfafc3b33c 100644 --- a/x-pack/plugins/case/server/saved_object_types/user_actions.ts +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -25,6 +25,9 @@ export const caseUserActionSavedObjectType: SavedObjectsType = { }, action_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index a2fe22a396787..d76d756bfa4f2 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -22,6 +22,7 @@ export const transformNewUserAction = ({ actionField, action, actionAt, + email, full_name, newValue = null, oldValue = null, @@ -30,6 +31,7 @@ export const transformNewUserAction = ({ actionField: UserActionField; action: UserAction; actionAt: string; + email?: string; full_name?: string; newValue?: string | null; oldValue?: string | null; @@ -38,7 +40,7 @@ export const transformNewUserAction = ({ action_field: actionField, action, action_at: actionAt, - action_by: { full_name, username }, + action_by: { email, full_name, username }, new_value: newValue, old_value: oldValue, }); From b4d9058eb098a1474fd26566535bec6653c5bb47 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Fri, 20 Mar 2020 23:41:40 -0400 Subject: [PATCH 08/17] integration APi to push to services with UI --- .../components/url_state/index.test.tsx | 2 +- .../siem/public/containers/case/api.ts | 45 +++++ .../case/configure/use_configure.tsx | 14 +- .../siem/public/containers/case/types.ts | 7 + .../case/use_get_action_license.tsx | 74 ++++++++ .../case/use_post_push_to_service.tsx | 178 ++++++++++++++++++ .../containers/case/use_update_case.tsx | 7 +- .../siem/public/containers/case/utils.ts | 8 + .../case/components/case_status/index.tsx | 85 ++++----- .../case/components/case_view/index.test.tsx | 11 ++ .../pages/case/components/case_view/index.tsx | 76 ++++++-- .../components/case_view/push_to_service.tsx | 137 ++++++++++++++ .../case/components/case_view/translations.ts | 67 +++++++ .../errors_push_service_callout/index.tsx | 30 +++ .../translations.ts | 21 +++ x-pack/plugins/case/common/api/cases/case.ts | 80 +++++++- .../cases/{pushed_case.ts => push_case.ts} | 48 +++-- .../plugins/case/server/routes/api/index.ts | 4 +- 18 files changed, 803 insertions(+), 91 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts rename x-pack/plugins/case/server/routes/api/cases/{pushed_case.ts => push_case.ts} (78%) diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 10aa388449d91..6e957313d9b04 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -158,7 +158,7 @@ describe('UrlStateContainer', () => { hash: '', pathname: examplePath, search: [CONSTANTS.timelinePage].includes(page) - ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))' + ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index d95d8d6bbc93d..39ab9da145dc4 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -14,9 +14,13 @@ import { CommentResponse, User, CaseUserActionsResponse, + CasePushRequest, + PushCaseParams, + PushCaseResponse, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { + ActionLicense, AllCases, BulkUpdateStatus, Case, @@ -37,6 +41,7 @@ import { decodeCasesStatusResponse, decodeCommentResponse, decodeCaseUserActionsResponse, + decodePushCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -179,3 +184,43 @@ export const deleteCases = async (caseIds: string[]): Promise => { }); return response === 'true' ? true : false; }; + +export const pushCase = async ( + caseId: string, + push: CasePushRequest, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/_push`, + { + method: 'POST', + body: JSON.stringify(push), + signal, + } + ); + return convertToCamelCase(decodeCaseResponse(response)); +}; + +export const pushToService = async ( + connectorId: string, + casePushParams: PushCaseParams, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `/api/action/${connectorId}/_execute`, + { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal, + } + ); + return decodePushCaseResponse(response); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`/api/action/types`, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index b81be645fa3a4..4d340ccf98605 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -25,8 +25,8 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string) => void; - setClosureType: (newClosureType: ClosureType) => void; + setConnectorId: (newConnectorId: string, newConnectorName?: string) => void; + setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ @@ -49,8 +49,10 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnectorId(res.connectorId, res.connectorName); + if (setClosureType != null) { + setClosureType(res.closureType); + } setVersion(res.version); } } @@ -98,7 +100,9 @@ export const useCaseConfigure = ({ if (!didCancel) { setPersistLoading(false); setConnectorId(res.connectorId); - setClosureType(res.closureType); + if (setClosureType) { + setClosureType(res.closureType); + } setVersion(res.version); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index f4224487dfeda..d3a88ba0b7b24 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -107,3 +107,10 @@ export interface BulkUpdateStatus { id: string; version: string; } +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx new file mode 100644 index 0000000000000..12f92b2db039b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getActionLicense } from './api'; +import * as i18n from './translations'; +import { ActionLicense } from './types'; + +interface ActionLicenseState { + actionLicense: ActionLicense | null; + isLoading: boolean; + isError: boolean; +} + +const initialData: ActionLicenseState = { + actionLicense: null, + isLoading: true, + isError: false, +}; + +export const useGetActionLicense = (): ActionLicenseState => { + const [actionLicenseState, setActionLicensesState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchActionLicense = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setActionLicensesState({ + ...actionLicenseState, + isLoading: true, + }); + try { + const response = await getActionLicense(abortCtrl.signal); + if (!didCancel) { + setActionLicensesState({ + actionLicense: response.find(l => l.id === '.servicenow') ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [actionLicenseState]); + + useEffect(() => { + fetchActionLicense(); + }, []); + return { ...actionLicenseState }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..bfc483cc93e1f --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useCallback } from 'react'; + +import { PushCaseResponse, PushCaseParams } from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster } from '../../components/toasters'; + +import { pushToService, pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + serviceData: PushCaseResponse | null; + pushedCaseData: Case | null; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: PushCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS_PUSH_SERVICE': + return { + ...state, + isLoading: false, + isError: false, + serviceData: action.payload ?? null, + }; + case 'FETCH_SUCCESS_PUSH_CASE': + return { + ...state, + isLoading: false, + isError: false, + pushedCaseData: action.payload ?? null, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + connectorId: string; + connectorName: string; + caseToPush: Case; + updateCase: (newCase: Case) => void; +} + +interface UsePostPushToService extends PushToServiceState { + postPushToService: ({ caseToPush, connectorId, updateCase }: PushToServiceRequest) => void; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const postPushToService = useCallback( + async ({ caseToPush, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + let cancel = false; + const abortCtrl = new AbortController(); + try { + dispatch({ type: 'FETCH_INIT' }); + const responseService = await pushToService( + connectorId, + formatServiceRequestData(caseToPush), + abortCtrl.signal + ); + const responseCase = await pushCase( + caseToPush.id, + { + connector_id: connectorId, + connector_name: connectorName, + external_id: responseService.incidentId, + external_title: responseService.number, + external_url: responseService.url, + }, + abortCtrl.signal + ); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); + dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); + updateCase(responseCase); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + return () => { + cancel = true; + abortCtrl.abort(); + }; + }, + [] + ); + + return { ...state, postPushToService }; +}; + +const formatServiceRequestData = (myCase: Case): PushCaseParams => { + const { + id: caseId, + createdAt, + createdBy, + comments, + description, + pushed, + title, + updatedAt, + updatedBy, + } = myCase; + + return { + caseId, + createdAt, + createdBy: { + fullName: createdBy.fullName ?? null, + username: createdBy?.username, + }, + comments: comments.map(c => ({ + commentId: c.id, + comment: c.comment, + createdAt: c.createdAt, + createdBy: { + fullName: c.createdBy.fullName ?? null, + username: c.createdBy.username, + }, + updatedAt: c.updatedAt, + updatedBy: + c.updatedBy != null + ? { + fullName: c.updatedBy.fullName ?? null, + username: c.updatedBy.username, + } + : null, + })), + description, + incidentId: pushed?.externalId ?? null, + title, + updatedAt, + updatedBy: + updatedBy != null + ? { + fullName: updatedBy.fullName ?? null, + username: updatedBy.username, + } + : null, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index dc14e0b18f7dc..f8af088f7e03b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -65,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; + updateCase: (newCase: Case) => void; } export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -75,6 +76,10 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase }); const [, dispatchToaster] = useStateToaster(); + const updateCase = useCallback((newCase: Case) => { + dispatch({ type: 'FETCH_SUCCESS', payload: newCase }); + }, []); + const dispatchUpdateCaseProperty = useCallback( async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; @@ -108,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase [state] ); - return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; + return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 63a1d3f3620f6..3831a7b1c6371 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -25,6 +25,8 @@ import { CaseConfigureResponseRt, CaseUserActionsResponse, CaseUserActionsResponseRt, + PushCaseResponseRt, + PushCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -94,3 +96,9 @@ export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsR CaseUserActionsResponseRt.decode(respUserActions), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodePushCaseResponse = (respPushCase?: PushCaseResponse) => + pipe( + PushCaseResponseRt.decode(respPushCase), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 9dbd71ea3e34c..0420a71fea907 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { EuiBadge, @@ -39,7 +39,7 @@ interface CaseStatusProps { isSelected: boolean; status: string; title: string; - toggleStatusCase: (status: string) => void; + toggleStatusCase: (evt: unknown) => void; value: string | null; } const CaseStatusComp: React.FC = ({ @@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({ title, toggleStatusCase, value, -}) => { - const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - return ( - - - - - - {i18n.STATUS} - - - {status} - - - - - {title} - - - - - - - - - - - - +}) => ( + + + + - + {i18n.STATUS} + + + {status} + + + + + {title} + + + - - - ); -}; + + + + + + + + + + + + + +); export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f4a83d1bff33..691df72d9f066 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -14,8 +14,10 @@ import { CaseComponent } from './'; import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; jest.mock('../../../../containers/case/use_update_case'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -47,6 +49,7 @@ const mockLocation = { describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -66,10 +69,18 @@ describe('CaseView ', () => { updateCaseProperty, }; + const defaultUseGetCaseUserActions = { + caseUserActions: [], + isLoading: false, + isError: false, + fetchCaseUserActions, + }; + beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); }); it('should render CaseComponent', () => { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 33cb453bb6bf3..e50853e8bc417 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui'; +import { + EuiButtonToggle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiHorizontalRule, +} from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; @@ -25,6 +32,7 @@ import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { usePushToService } from './push_to_service'; interface Props { caseId: string; @@ -38,6 +46,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; +const MyEuiHorizontalRule = styled(EuiHorizontalRule)` + margin-left: 48px; + &.euiHorizontalRule--full { + width: calc(100% - 48px); + } +`; + export interface CaseProps { caseId: string; initialData: Case; @@ -52,7 +67,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => isLoading: isLoadingUserActions, fetchCaseUserActions, } = useGetCaseUserActions(caseId); - const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); + const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( + caseId, + initialData + ); // Update Fields const onUpdateField = useCallback( @@ -102,6 +120,19 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => [fetchCaseUserActions, updateCaseProperty, caseData.status] ); + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(newCase.id); + }, + [updateCase, fetchCaseUserActions] + ); + const { pushButton, pushCallouts } = usePushToService({ + caseData, + isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + updateCase: handleUpdateCase, + }); + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); @@ -123,7 +154,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } : { 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt, + value: caseData.closedAt ?? '', title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, @@ -141,6 +172,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => [caseData.title] ); + const onChangeStatus = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ + toggleStatusCase, + ]); + useEffect(() => { if (initLoadingData && !isLoadingUserActions) { setInitLoadingData(false); @@ -169,25 +204,42 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => caseId={caseData.id} caseTitle={caseData.title} isLoading={isLoading && updateKey === 'status'} - toggleStatusCase={toggleStatusCase} + toggleStatusCase={onChangeStatus} {...caseStatusData} />
+ {pushCallouts != null && pushCallouts} {initLoadingData && } {!initLoadingData && ( - + <> + + + + + + + {pushButton} + + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx new file mode 100644 index 0000000000000..7d975d21e44ce --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useState, useMemo } from 'react'; + +import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; +import { Case } from '../../../../containers/case/types'; +import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; +import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; + +import * as i18n from './translations'; +import { ErrorsPushServiceCallOut } from '../errors_push_service_callout'; + +interface UsePushToService { + caseData: Case; + isNew: boolean; + updateCase: (newCase: Case) => void; +} + +interface Connector { + connectorId: string; + connectorName: string; +} + +interface ReturnUsePushToService { + pushButton: JSX.Element; + pushCallouts: JSX.Element | null; +} + +export const usePushToService = ({ + caseData, + updateCase, + isNew, +}: UsePushToService): ReturnUsePushToService => { + const [connector, setConnector] = useState(null); + + const { isLoading, postPushToService } = usePostPushToService(); + + const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { + setConnector({ connectorId, connectorName: connectorName ?? '' }); + }, []); + + const { loading: loadingCaseConfigure } = useCaseConfigure({ + setConnectorId: handleSetConnector, + }); + + const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); + + const handlePushToService = useCallback(() => { + if (connector != null) { + postPushToService({ + caseToPush: caseData, + ...connector, + updateCase, + }); + } + }, [caseData, connector, postPushToService, updateCase]); + + const errorsMsg = useMemo(() => { + let errors: Array<{ title: string; description: string }> = []; + if (caseData.status === 'closed') { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_DESCRIPTION, + }, + ]; + } + if (connector == null && !loadingCaseConfigure && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_DESCRIPTION, + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: i18n.PUSH_DISABLE_BY_LICENSE_DESCRIPTION, + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInConfig) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, + description: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_DESCRIPTION, + }, + ]; + } + return errors; + }, [actionLicense, caseData, connector, loadingCaseConfigure, loadingLicense]); + + const pushToServiceButton = useMemo( + () => ( + 0} + isLoading={isLoading} + > + {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + + ), + [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg] + ); + + const objToReturn = useMemo( + () => ({ + pushButton: + errorsMsg.length > 0 ? ( + {errorsMsg[0].description}

} + > + {pushToServiceButton} +
+ ) : ( + <>{pushToServiceButton} + ), + pushCallouts: errorsMsg.length > 0 ? : null, + }), + [errorsMsg, pushToServiceButton] + ); + return objToReturn; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index a2fcc5d07980a..73aa5a47182c4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -88,3 +88,70 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + +export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { + defaultMessage: 'Push as ServiceNow incident', +}); + +export const UPDATE_PUSH_SERVICENOW = i18n.translate( + 'xpack.siem.case.caseView.updatePushAsServicenowIncident', + { + defaultMessage: 'Update ServiceNow incident', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', + { + defaultMessage: 'Configure case', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription', + { + defaultMessage: 'You did not configure you case system', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', + { + defaultMessage: 'Case closed', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription', + { + defaultMessage: 'You cannot push a case who have been closed', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', + { + defaultMessage: 'Connector kibana config', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigDescription', + { + defaultMessage: 'ServiceNow connector have been disabled in kibana config', + } +); + +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', + { + defaultMessage: 'Elastic Stack subscriptions', + } +); + +export const PUSH_DISABLE_BY_LICENSE_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseDescription', + { + defaultMessage: 'ServiceNow is disabled because you do not have the right license', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx new file mode 100644 index 0000000000000..37fea6f624536 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +interface ErrorsPushServiceCallOut { + errors: Array<{ title: string; description: string }>; +} + +const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + + + + {i18n.DISMISS_CALLOUT} + + + ) : null; +}; + +export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts new file mode 100644 index 0000000000000..cfd26632e6a36 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.case.errorsPushServiceCallOutTitle', + { + defaultMessage: 'You can not push to ServiceNow because of the errors below', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 9fff630d4ff2b..a5d73acc10502 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -28,6 +28,17 @@ const CasePushBasicRt = rt.type({ external_url: rt.string, }); +const CasePushedBasicRt = rt.union([ + rt.intersection([ + CasePushBasicRt, + rt.type({ + at: rt.string, + by: UserRT, + }), + ]), + rt.null, +]); + export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ @@ -35,16 +46,7 @@ export const CaseAttributesRt = rt.intersection([ closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, - pushed: rt.union([ - rt.intersection([ - CasePushBasicRt, - rt.type({ - at: rt.string, - by: UserRT, - }), - ]), - rt.null, - ]), + pushed: CasePushedBasicRt, updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -98,6 +100,60 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +/* + * This type are related to this file below + * x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * why because this schema is not share in a common folder + * so we redefine then so we can use/validate types + */ + +const PushCaseUserParams = rt.type({ + fullName: rt.union([rt.string, rt.null]), + username: rt.string, +}); + +export const PushCommentParamsRt = rt.type({ + commentId: rt.string, + comment: rt.string, + createdAt: rt.string, + createdBy: PushCaseUserParams, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([PushCaseUserParams, rt.null]), +}); + +export const PushCaseParamsRt = rt.intersection([ + rt.type({ + caseId: rt.string, + createdAt: rt.string, + createdBy: PushCaseUserParams, + incidentId: rt.union([rt.string, rt.null]), + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([PushCaseUserParams, rt.null]), + }), + rt.partial({ + description: rt.string, + comments: rt.array(PushCommentParamsRt), + }), +]); + +export const PushCaseResponseRt = rt.intersection([ + rt.type({ + number: rt.string, + incidentId: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CaseRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -106,3 +162,7 @@ export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; export type CasePushRequest = rt.TypeOf; +export type PushCaseParams = rt.TypeOf; +export type PushCaseResponse = rt.TypeOf; +export type CasePushedData = rt.TypeOf; +export type PushCommentParams = rt.TypeOf; diff --git a/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts similarity index 78% rename from x-pack/plugins/case/server/routes/api/cases/pushed_case.ts rename to x-pack/plugins/case/server/routes/api/cases/push_case.ts index 23e6baa79c446..471e86237e564 100644 --- a/x-pack/plugins/case/server/routes/api/cases/pushed_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -16,10 +16,15 @@ import { CasePushRequestRt, CaseResponseRt, throwErrors } from '../../../../comm import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPushedCaseUserActionApi({ caseService, router, userActionService }: RouteDeps) { +export function initPushCaseUserActionApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { router.post( { - path: '/api/cases/{case_id}/_pushed', + path: '/api/cases/{case_id}/_push', validate: { params: schema.object({ case_id: schema.string(), @@ -38,20 +43,28 @@ export function initPushedCaseUserActionApi({ caseService, router, userActionSer const pushedBy = await caseService.getUser({ request, response }); const pushedDate = new Date().toISOString(); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - }); + const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ + caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }), + ]); - const totalCommentsFindByCases = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }); + if (myCase.attributes.status === 'closed') { + throw Boom.conflict( + `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` + ); + } const comments = await caseService.getAllCaseComments({ client, @@ -74,6 +87,11 @@ export function initPushedCaseUserActionApi({ caseService, router, userActionSer client, caseId, updatedAttributes: { + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: 'closed', + } + : {}), pushed, updated_at: pushedDate, updated_by: pushedBy, diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 1fe7d77a7c662..ced88fabf3160 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -9,7 +9,7 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; -import { initPushedCaseUserActionApi } from './cases/pushed_case'; +import { initPushCaseUserActionApi } from './cases/push_case'; import { initGetReportersApi } from './cases/reporters/get_reporters'; import { initGetCasesStatusApi } from './cases/status/get_status'; import { initGetTagsApi } from './cases/tags/get_tags'; @@ -37,7 +37,7 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); - initPushedCaseUserActionApi(deps); + initPushCaseUserActionApi(deps); initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); From bb90c3566a0c8d0c9ab603a59e1a1e7d3fecad39 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Sat, 21 Mar 2020 08:09:03 -0400 Subject: [PATCH 09/17] wip to show pushed service in ui --- .../case/use_get_case_user_actions.tsx | 7 +- .../pages/case/components/case_view/index.tsx | 10 ++- .../components/user_action_tree/index.tsx | 2 +- .../user_action_tree/user_action_item.tsx | 64 ++++++++++++++++--- .../case/common/api/cases/user_actions.ts | 1 + .../case/server/routes/api/cases/push_case.ts | 2 +- 6 files changed, 72 insertions(+), 14 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index 0e3e5fe59d54f..b1df4aae6a60b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -51,7 +51,12 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => // We are removing the first item because it will always the creation of the case // and we do not want it to simplify our life setCaseUserActionsState({ - caseUserActions: !isEmpty(response) ? response.slice(1) : [], + caseUserActions: !isEmpty(response) + ? [ + ...response.slice(1), + { ...response[response.length - 1], actionId: 33, action: 'push-to-service' }, + ] + : [], isLoading: false, isError: false, }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index e50853e8bc417..f7ee4b081a755 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -138,7 +138,13 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); - + const hasDataToPush = useMemo(() => { + const indexPushToService = caseUserActions.findIndex(cua => cua.action === 'push-to-service'); + if (indexPushToService === -1 || indexPushToService < caseUserActions.length - 1) { + return true; + } + return false; + }, [caseUserActions]); const caseStatusData = useMemo( () => caseData.status === 'open' @@ -237,7 +243,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => onChange={onChangeStatus} />
- {pushButton} + {hasDataToPush && {pushButton}}
)} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index f1a0fda12d072..9c2303a68df39 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -15,7 +15,7 @@ import { useUpdateComment } from '../../../../containers/case/use_update_comment import { useCurrentUser } from '../../../../lib/kibana'; import { AddComment } from '../add_comment'; import { getLabelTitle } from './helpers'; -import { UserActionItem } from './user_action_item'; +import { UserActionItem, UserActionItemContainer } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; export interface UserActionTreeProps { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 0b7dd5583026c..8efb6ed5e9351 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiHorizontalRule, +} from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; @@ -28,7 +34,7 @@ interface UserActionItemProps { idToOutline?: string | null; } -const UserActionItemContainer = styled(EuiFlexGroup)` +export const UserActionItemContainer = styled(EuiFlexGroup)` ${({ theme }) => css` & { background-image: linear-gradient( @@ -80,6 +86,18 @@ const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` ` : ''} `; +const PushedContainer = styled(EuiFlexItem)` + margin-top: 8px; + margin-bottom: 24px; + hr { + margin: 5px; + height: 2px; + } +`; + +const PushedInfoContainer = styled.div` + margin-left: 48px; +`; export const UserActionItem = ({ createdAt, @@ -97,13 +115,41 @@ export const UserActionItem = ({ userName, updatedAt, }: UserActionItemProps) => ( - - - {fullName.length > 0 || userName.length > 0 ? ( - - ) : ( - - )} + + + + + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} + + + {isEditable && markdown} + {!isEditable && ( + + } + linkId={linkId} + userName={userName} + updatedAt={updatedAt} + onEdit={onEdit} + outlineComment={outlineComment} + /> + {markdown} + + )} + + {isEditable && markdown} diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 6bc74ea04e119..2b70a698a5152 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -15,6 +15,7 @@ const UserActionFieldRt = rt.array( rt.union([ rt.literal('comment'), rt.literal('description'), + rt.literal('pushed'), rt.literal('tags'), rt.literal('title'), rt.literal('status'), diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 471e86237e564..05d2b35c81481 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -121,7 +121,7 @@ export function initPushCaseUserActionApi({ actionAt: pushedDate, actionBy: pushedBy, caseId, - fields: [], + fields: ['pushed'], newValue: JSON.stringify(pushed), }), ], From f557058f34861da222cd1695d2204ff72146d3cc Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 21 Mar 2020 13:43:32 +0200 Subject: [PATCH 10/17] Fix bugs --- x-pack/legacy/plugins/siem/public/containers/case/api.ts | 5 +++-- x-pack/plugins/case/common/api/cases/case.ts | 2 ++ x-pack/plugins/case/server/routes/api/cases/push_case.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 39ab9da145dc4..5b57159cace5f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -17,6 +17,7 @@ import { CasePushRequest, PushCaseParams, PushCaseResponse, + ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { @@ -206,7 +207,7 @@ export const pushToService = async ( casePushParams: PushCaseParams, signal: AbortSignal ): Promise => { - const response = await KibanaServices.get().http.fetch( + const response = await KibanaServices.get().http.fetch( `/api/action/${connectorId}/_execute`, { method: 'POST', @@ -214,7 +215,7 @@ export const pushToService = async ( signal, } ); - return decodePushCaseResponse(response); + return decodePushCaseResponse(response.data); }; export const getActionLicense = async (signal: AbortSignal): Promise => { diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index a5d73acc10502..627c3d2b04608 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -11,6 +11,8 @@ import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; +export { ActionTypeExecutorResult } from '../../../../actions/server/types'; + const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 05d2b35c81481..7e6dca19445de 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -29,7 +29,7 @@ export function initPushCaseUserActionApi({ params: schema.object({ case_id: schema.string(), }), - query: escapeHatch, + body: escapeHatch, }, }, async (context, request, response) => { @@ -37,7 +37,7 @@ export function initPushCaseUserActionApi({ const client = context.core.savedObjects.client; const caseId = request.params.case_id; const query = pipe( - CasePushRequestRt.decode(request.query), + CasePushRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const pushedBy = await caseService.getUser({ request, response }); From 4f4ea85a6a6bfc628a81e2f2d82384b19c3c0a3d Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Sat, 21 Mar 2020 23:48:16 -0400 Subject: [PATCH 11/17] finish the full flow with pushing to service now --- .../case/use_get_case_user_actions.tsx | 39 +++++++-- .../components/case_view/__mock__/index.tsx | 18 +++++ .../case/components/case_view/index.test.tsx | 34 ++++++-- .../pages/case/components/case_view/index.tsx | 45 ++++++----- .../components/case_view/push_to_service.tsx | 1 + .../case/components/case_view/translations.ts | 14 ++++ .../components/user_action_tree/helpers.tsx | 80 ++++++++++++------- .../components/user_action_tree/index.tsx | 49 ++++++++++-- .../user_action_tree/translations.ts | 21 +++++ .../user_action_tree/user_action_item.tsx | 64 ++++++++------- .../user_action_tree/user_action_title.tsx | 26 +++--- .../plugins/siem/public/pages/case/index.tsx | 4 +- .../siem/public/pages/case/translations.ts | 6 +- .../routes/api/__fixtures__/mock_router.ts | 9 ++- .../api/cases/comments/delete_all_comments.ts | 4 +- .../api/cases/comments/delete_comment.ts | 4 +- .../api/cases/comments/patch_comment.ts | 5 +- .../routes/api/cases/comments/post_comment.ts | 17 ++-- .../api/cases/configure/patch_configure.ts | 3 +- .../api/cases/configure/post_configure.ts | 3 +- .../server/routes/api/cases/delete_cases.ts | 4 +- .../routes/api/cases/patch_cases.test.ts | 6 +- .../server/routes/api/cases/patch_cases.ts | 3 +- .../case/server/routes/api/cases/post_case.ts | 8 +- .../case/server/routes/api/cases/push_case.ts | 50 +++++++----- .../case/server/saved_object_types/cases.ts | 3 + x-pack/plugins/case/server/services/index.ts | 10 ++- 27 files changed, 364 insertions(+), 166 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index b1df4aae6a60b..cdc0f9cab5a6d 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -14,12 +14,18 @@ import { CaseUserActions } from './types'; interface CaseUserActionsState { caseUserActions: CaseUserActions[]; + firstIndexPushToService: number; + hasDataToPush: boolean; isLoading: boolean; isError: boolean; + lastIndexPushToService: number; } const initialData: CaseUserActionsState = { caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, isLoading: true, isError: false, }; @@ -28,6 +34,25 @@ interface UseGetCaseUserActions extends CaseUserActionsState { fetchCaseUserActions: (caseId: string) => void; } +const getPushedInfo = ( + caseUserActions: CaseUserActions[] +): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { + const firstIndexPushToService = caseUserActions.findIndex( + cua => cua.action === 'push-to-service' + ); + const lastIndexPushToService = caseUserActions + .map(cua => cua.action) + .lastIndexOf('push-to-service'); + + const hasDataToPush = + lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + return { + firstIndexPushToService, + lastIndexPushToService, + hasDataToPush, + }; +}; + export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { const [caseUserActionsState, setCaseUserActionsState] = useState( initialData @@ -48,15 +73,12 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); if (!didCancel) { // Attention Future developer - // We are removing the first item because it will always the creation of the case + // We are removing the first item because it will always be the creation of the case // and we do not want it to simplify our life + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; setCaseUserActionsState({ - caseUserActions: !isEmpty(response) - ? [ - ...response.slice(1), - { ...response[response.length - 1], actionId: 33, action: 'push-to-service' }, - ] - : [], + caseUserActions, + ...getPushedInfo(caseUserActions), isLoading: false, isError: false, }); @@ -70,6 +92,9 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => }); setCaseUserActionsState({ caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, isLoading: false, isError: true, }); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index 5eeaa46727242..fbd461242bdb5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -67,3 +67,21 @@ export const data: Case = { export const dataClosed: Case = { ...caseClosedProps.initialData, }; + +export const caseUserActions = [ + { + actionField: ['comment'], + action: 'create', + actionAt: '2020-03-20T17:10:09.814Z', + actionBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', + }, + newValue: 'Solve this fast!', + oldValue: null, + actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 691df72d9f066..3c95ba8a4f0bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -11,13 +11,18 @@ import { mount } from 'enzyme'; import routeData from 'react-router'; /* eslint-enable @kbn/eslint/module_migration */ import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { wait } from '../../../../lib/helpers'; +import { usePushToService } from './push_to_service'; jest.mock('../../../../containers/case/use_update_case'); +jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('./push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const usePushToServiceMock = usePushToService as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -70,10 +75,18 @@ describe('CaseView ', () => { }; const defaultUseGetCaseUserActions = { - caseUserActions: [], + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, isLoading: false, isError: false, - fetchCaseUserActions, + lastIndexPushToService: -1, + }; + + const defaultUsePushToServiceMock = { + pushButton: <>{'Hello Button'}, + pushCallouts: null, }; beforeEach(() => { @@ -81,9 +94,10 @@ describe('CaseView ', () => { useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); + usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); }); - it('should render CaseComponent', () => { + it('should render CaseComponent', async () => { const wrapper = mount( @@ -91,6 +105,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find(`[data-test-subj="case-view-title"]`) @@ -130,7 +145,7 @@ describe('CaseView ', () => { ).toEqual(data.description); }); - it('should show closed indicators in header when case is closed', () => { + it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, caseData: dataClosed, @@ -142,6 +157,7 @@ describe('CaseView ', () => { ); + await wait(); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); expect( wrapper @@ -157,7 +173,7 @@ describe('CaseView ', () => { ).toEqual(dataClosed.status); }); - it('should dispatch update state when button is toggled', () => { + it('should dispatch update state when button is toggled', async () => { const wrapper = mount( @@ -165,18 +181,19 @@ describe('CaseView ', () => { ); - + await wait(); wrapper .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ + fetchCaseUserActions, updateKey: 'status', updateValue: 'closed', }); }); - it('should render comments', () => { + it('should render comments', async () => { const wrapper = mount( @@ -184,6 +201,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index f7ee4b081a755..dd6917b2f3806 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -15,6 +15,7 @@ import { import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; +import { uniqBy } from 'lodash/fp'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -64,8 +65,11 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, - isLoading: isLoadingUserActions, fetchCaseUserActions, + firstIndexPushToService, + hasDataToPush, + isLoading: isLoadingUserActions, + lastIndexPushToService, } = useGetCaseUserActions(caseId); const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( caseId, @@ -119,7 +123,6 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => }, [fetchCaseUserActions, updateCaseProperty, caseData.status] ); - const handleUpdateCase = useCallback( (newCase: Case) => { updateCase(newCase); @@ -127,6 +130,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => }, [updateCase, fetchCaseUserActions] ); + const { pushButton, pushCallouts } = usePushToService({ caseData, isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, @@ -135,16 +139,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); - const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'closed' : 'open'), + [onUpdateField] + ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); - const hasDataToPush = useMemo(() => { - const indexPushToService = caseUserActions.findIndex(cua => cua.action === 'push-to-service'); - if (indexPushToService === -1 || indexPushToService < caseUserActions.length - 1) { - return true; - } - return false; - }, [caseUserActions]); + const participants = useMemo( + () => uniqBy('actionBy.username', caseUserActions).map(cau => cau.actionBy), + [caseUserActions] + ); const caseStatusData = useMemo( () => caseData.status === 'open' @@ -175,13 +178,9 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => subject: i18n.EMAIL_SUBJECT(caseData.title), body: i18n.EMAIL_BODY(caseLink), }), - [caseData.title] + [caseLink, caseData.title] ); - const onChangeStatus = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - useEffect(() => { if (initLoadingData && !isLoadingUserActions) { setInitLoadingData(false); @@ -210,7 +209,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => caseId={caseData.id} caseTitle={caseData.title} isLoading={isLoading && updateKey === 'status'} - toggleStatusCase={onChangeStatus} + toggleStatusCase={toggleStatusCase} {...caseStatusData} /> @@ -227,8 +226,10 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => caseUserActions={caseUserActions} data={caseData} fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)} + firstIndexPushToService={firstIndexPushToService} isLoadingDescription={isLoading && updateKey === 'description'} isLoadingUserActions={isLoadingUserActions} + lastIndexPushToService={lastIndexPushToService} onUpdateField={onUpdateField} /> @@ -240,7 +241,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => isSelected={caseStatusData.isSelected} isLoading={isLoading && updateKey === 'status'} label={caseStatusData.buttonLabel} - onChange={onChangeStatus} + onChange={toggleStatusCase} /> {hasDataToPush && {pushButton}} @@ -250,11 +251,17 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + ( 0} isLoading={isLoading} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index 73aa5a47182c4..d869e962c44f6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -34,6 +34,20 @@ export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabe defaultMessage: 'removed', }); +export const PUSHED_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.pushedNewIncident', + { + defaultMessage: 'pushed as new incident', + } +); + +export const UPDATE_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.updateIncident', + { + defaultMessage: 'updated incident', + } +); + export const ADDED_DESCRIPTION = i18n.translate( 'xpack.siem.case.caseView.actionLabel.addDescription', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx index a91435869cee4..2a7197b2c635b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -4,42 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; +import { CasePushedData } from '../../../../../../../../plugins/case/common/api'; import { CaseUserActions } from '../../../../containers/case/types'; import * as i18n from '../case_view/translations'; -export const getLabelTitle = (field: string, action: CaseUserActions) => { - if (field === 'tags' && action.action === 'add') { - return ( - - - {i18n.ADDED_FIELD} {i18n.TAGS.toLowerCase()} - - {action.newValue != null && - action.newValue.split(',').map(tag => ( - - {tag} - - ))} - - ); - } - if (field === 'tags' && action.action === 'delete') { - return ( - - - {i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - - {action.newValue != null && - action.newValue.split(',').map(tag => ( - - {tag} - - ))} - - ); +interface LabelTitle { + action: CaseUserActions; + field: string; + firstIndexPushToService: number; + index: number; +} + +export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { + if (field === 'tags') { + return getTagsLabelTitle(action); } else if (field === 'title' && action.action === 'update') { return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ action.newValue @@ -52,6 +33,43 @@ export const getLabelTitle = (field: string, action: CaseUserActions) => { } ${i18n.CASE}`; } else if (field === 'comment' && action.action === 'update') { return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { + return getPushedServiceLabelTitle(action, firstIndexPushToService, index); } return ''; }; + +const getTagsLabelTitle = (action: CaseUserActions) => ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + +); + +const getPushedServiceLabelTitle = ( + action: CaseUserActions, + firstIndexPushToService: number, + index: number +) => { + const pushedVal = JSON.parse(action.newValue ?? '') as CasePushedData; + return ( + + + {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + + + + {pushedVal?.connector_name} {pushedVal?.external_title} + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 9c2303a68df39..e41e14a6cc1f1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -5,7 +5,8 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import * as i18n from '../case_view/translations'; @@ -15,15 +16,17 @@ import { useUpdateComment } from '../../../../containers/case/use_update_comment import { useCurrentUser } from '../../../../lib/kibana'; import { AddComment } from '../add_comment'; import { getLabelTitle } from './helpers'; -import { UserActionItem, UserActionItemContainer } from './user_action_item'; +import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; export interface UserActionTreeProps { data: Case; caseUserActions: CaseUserActions[]; + fetchUserActions: () => void; + firstIndexPushToService: number; isLoadingDescription: boolean; isLoadingUserActions: boolean; - fetchUserActions: () => void; + lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } @@ -39,11 +42,15 @@ export const UserActionTree = React.memo( data: caseData, caseUserActions, fetchUserActions, + firstIndexPushToService, isLoadingDescription, isLoadingUserActions, + lastIndexPushToService, onUpdateField, }: UserActionTreeProps) => { + const { commentId } = useParams(); const handlerTimeoutId = useRef(0); + const [initLoading, setInitLoading] = useState(true); const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments @@ -77,6 +84,15 @@ export const UserActionTree = React.memo( const handleOutlineComment = useCallback( (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } window.clearTimeout(handlerTimeoutId.current); setSelectedOutlineCommentId(id); handlerTimeoutId.current = window.setTimeout(() => { @@ -123,6 +139,15 @@ export const UserActionTree = React.memo( [caseData.id, handleUpdate] ); + useEffect(() => { + if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); + return ( <> - {caseUserActions.map(action => { + + {caseUserActions.map((action, index) => { if (action.commentId != null && action.action === 'create') { const comment = comments.find(c => c.id === action.commentId); if (comment != null) { @@ -171,7 +197,12 @@ export const UserActionTree = React.memo( } if (action.actionField.length === 1) { const myField = action.actionField[0]; - const labelTitle: string | JSX.Element = getLabelTitle(myField, action); + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstIndexPushToService, + index, + }); return ( ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts new file mode 100644 index 0000000000000..3958938e9c126 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.alreadyPushedToService', + { + defaultMessage: 'Already pushed to Service Now incident', + } +); + +export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.requiredUpdateToService', + { + defaultMessage: 'Requires update to ServiceNow incident', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index 8efb6ed5e9351..dc51e6f3e83fb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -10,12 +10,14 @@ import { EuiLoadingSpinner, EuiPanel, EuiHorizontalRule, + EuiText, } from '@elastic/eui'; import React from 'react'; import styled, { css } from 'styled-components'; import { UserActionAvatar } from './user_action_avatar'; import { UserActionTitle } from './user_action_title'; +import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; @@ -31,6 +33,8 @@ interface UserActionItemProps { userName: string; updatedAt?: string | null; outlineComment?: (id: string) => void; + showBottomFooter?: boolean; + showTopFooter?: boolean; idToOutline?: string | null; } @@ -86,13 +90,16 @@ const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` ` : ''} `; + const PushedContainer = styled(EuiFlexItem)` - margin-top: 8px; - margin-bottom: 24px; - hr { - margin: 5px; - height: 2px; - } + ${({ theme }) => ` + margin-top: ${theme.eui.euiSizeS}; + margin-bottom: ${theme.eui.euiSizeXL}; + hr { + margin: 5px; + height: ${theme.eui.euiBorderWidthThick}; + } + `} `; const PushedInfoContainer = styled.div` @@ -112,6 +119,8 @@ export const UserActionItem = ({ markdown, onEdit, outlineComment, + showBottomFooter, + showTopFooter, userName, updatedAt, }: UserActionItemProps) => ( @@ -137,7 +146,7 @@ export const UserActionItem = ({ createdAt={createdAt} id={id} isLoading={isLoading} - labelAction={labelAction} + labelAction={labelEditAction} labelTitle={labelTitle ?? <>} linkId={linkId} userName={userName} @@ -151,29 +160,22 @@ export const UserActionItem = ({ - - {isEditable && markdown} - {!isEditable && ( - - } - linkId={linkId} - userName={userName} - updatedAt={updatedAt} - onEdit={onEdit} - outlineComment={outlineComment} - /> - {markdown} - - )} - + {showTopFooter && ( + + + + {i18n.ALREADY_PUSHED_TO_SERVICE} + + + + {showBottomFooter && ( + + + {i18n.REQUIRED_UPDATE_TO_SERVICE} + + + )} + + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 289eebae6c527..d3c94a1f265d6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -65,7 +65,7 @@ export const UserActionTitle = ({ }, [id, labelEditAction, onEdit]); const handleAnchorLink = useCallback(() => { - copy(`${window.location.origin}${window.location.pathname}#case/${caseId}/id${urlSearch}`, { + copy(`${window.location.origin}${window.location.pathname}#case/${caseId}/${id}${urlSearch}`, { debug: true, }); }, [caseId, id, urlSearch]); @@ -74,15 +74,6 @@ export const UserActionTitle = ({ if (outlineComment != null && linkId != null) { outlineComment(linkId); } - const moveToTarget = document.getElementById(`${linkId}-permLink`); - if (moveToTarget != null) { - const yOffset = -60; - const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; - window.scrollTo({ - top: y, - behavior: 'smooth', - }); - } }, [linkId, outlineComment]); return ( @@ -128,11 +119,22 @@ export const UserActionTitle = ({ {!isEmpty(linkId) && ( - + )} - + {propertyActions.length > 0 && ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index cecb38816781b..124cefa726a8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -30,10 +30,10 @@ const CaseContainerComponent: React.FC = () => ( - + - + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index f91e623197b2f..4bb07053a9916 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -34,10 +34,14 @@ export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { +export const REPORTER = i18n.translate('xpack.siem.case.caseView.reporterLabel', { defaultMessage: 'Reporter', }); +export const PARTICIPANTS = i18n.translate('xpack.siem.case.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { defaultMessage: 'Create', }); diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index 0945ded8d57c3..eff91fff32c02 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -6,7 +6,7 @@ import { IRouter } from 'kibana/server'; import { loggingServiceMock, httpServiceMock } from '../../../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService, CaseUserActionService } from '../../../services'; +import { CaseService, CaseConfigureService } from '../../../services'; import { authenticationMock } from '../__fixtures__'; import { RouteDeps } from '../types'; @@ -22,19 +22,20 @@ export const createRoute = async ( const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); - const userActionServicePlugin = new CaseUserActionService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); - const userActionService = await userActionServicePlugin.setup(); api({ caseConfigureService, caseService, router, - userActionService, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 8f0e616ceacbb..a1279ecd8247f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -23,7 +23,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic async (context, request, response) => { try { const client = context.core.savedObjects.client; - const deletedBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ @@ -45,7 +45,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic buildCommentUserActionItem({ action: 'delete', actionAt: deleteDate, - actionBy: deletedBy, + actionBy: { username, full_name, email }, caseId: request.params.case_id, commentId: comment.id, fields: ['comment'], diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 8587d24a85102..8d390ddb7ed17 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -26,7 +26,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: async (context, request, response) => { try { const client = context.core.savedObjects.client; - const deletedBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ @@ -56,7 +56,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: buildCommentUserActionItem({ action: 'delete', actionAt: deleteDate, - actionBy: deletedBy, + actionBy: { username, full_name, email }, caseId: request.params.case_id, commentId: request.params.comment_id, fields: ['comment'], diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index ded5a8eefc03a..2299591884354 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -57,9 +57,8 @@ export function initPatchCommentApi({ caseService, router, userActionService }: ); } - const updatedBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); - const { email, full_name, username } = updatedBy; const updatedComment = await caseService.patchComment({ client: context.core.savedObjects.client, commentId: query.id, @@ -77,7 +76,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: buildCommentUserActionItem({ action: 'update', actionAt: updatedDate, - actionBy: updatedBy, + actionBy: { username, full_name, email }, caseId: request.params.case_id, commentId: updatedComment.id, fields: ['comment'], diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 4c5405a1aef5e..c0a321ab7b86a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -40,7 +40,12 @@ export function initPostCommentApi({ caseService, router, userActionService }: R fold(throwErrors(Boom.badRequest), identity) ); - const createdBy = await caseService.getUser({ request, response }); + const myCase = await caseService.getCase({ + client: context.core.savedObjects.client, + caseId: request.params.case_id, + }); + + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newComment = await caseService.postNewComment({ @@ -48,13 +53,15 @@ export function initPostCommentApi({ caseService, router, userActionService }: R attributes: transformNewComment({ createdDate, ...query, - ...createdBy, + username, + full_name, + email, }), references: [ { type: CASE_SAVED_OBJECT, name: `associated-${CASE_SAVED_OBJECT}`, - id: request.params.case_id, + id: myCase.id, }, ], }); @@ -65,8 +72,8 @@ export function initPostCommentApi({ caseService, router, userActionService }: R buildCommentUserActionItem({ action: 'create', actionAt: createdDate, - actionBy: createdBy, - caseId: request.params.case_id, + actionBy: { username, full_name, email }, + caseId: myCase.id, commentId: newComment.id, fields: ['comment'], newValue: query.comment, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1542394fc438d..3a1b9d5059cbc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -48,8 +48,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index c839d36dcf4df..2a23abf0cbf21 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -42,8 +42,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ) ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { email, full_name, username } = await caseService.getUser({ request, response }); const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index ea97122bd322c..8b0384c12edce 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -54,7 +54,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R ) ); } - const deletedBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const deleteDate = new Date().toISOString(); await userActionService.postUserActions({ @@ -63,7 +63,7 @@ export function initDeleteCasesApi({ caseService, router, userActionService }: R buildCaseUserActionItem({ action: 'create', actionAt: deleteDate, - actionBy: deletedBy, + actionBy: { username, full_name, email }, caseId: id, fields: ['comment', 'description', 'status', 'tags', 'title'], }) diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 19ff7f0734a77..3c3e111cfa901 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -52,15 +52,16 @@ describe('PATCH cases', () => { { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', + pushed: null, status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', @@ -94,15 +95,16 @@ describe('PATCH cases', () => { { closed_at: null, closed_by: null, - comment_ids: [], comments: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', + pushed: null, status: 'open', tags: ['LOLBins'], title: 'Another bad one', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index e9102f7916348..62c41d872e2c8 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -74,8 +74,7 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro return Object.keys(updateCaseAttributes).length > 0; }); if (updateFilterCases.length > 0) { - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ client, diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 0c881f1b11c87..75be68013bcd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -31,14 +31,16 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout fold(throwErrors(Boom.badRequest), identity) ); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newCase = await caseService.postNewCase({ client, attributes: transformNewCase({ createdDate, newCase: query, - ...createdBy, + username, + full_name, + email, }), }); @@ -48,7 +50,7 @@ export function initPostCaseApi({ caseService, router, userActionService }: Rout buildCaseUserActionItem({ action: 'create', actionAt: createdDate, - actionBy: createdBy, + actionBy: { username, full_name, email }, caseId: newCase.id, fields: ['description', 'status', 'tags', 'title'], newValue: JSON.stringify(query), diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 7e6dca19445de..be1793e43cd44 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -40,7 +40,7 @@ export function initPushCaseUserActionApi({ CasePushRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const pushedBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const pushedDate = new Date().toISOString(); const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ @@ -78,7 +78,7 @@ export function initPushCaseUserActionApi({ const pushed = { at: pushedDate, - by: pushedBy, + by: { username, full_name, email }, ...query, }; @@ -94,7 +94,7 @@ export function initPushCaseUserActionApi({ : {}), pushed, updated_at: pushedDate, - updated_by: pushedBy, + updated_by: { username, full_name, email }, }, version: myCase.version, }), @@ -104,29 +104,41 @@ export function initPushCaseUserActionApi({ commentId: comment.id, updatedAttributes: { pushed_at: pushedDate, - pushed_by: pushedBy, + pushed_by: { username, full_name, email }, updated_at: pushedDate, - updated_by: pushedBy, + updated_by: { username, full_name, email }, }, version: comment.version, })), }), + userActionService.postUserActions({ + client, + actions: [ + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: [status], + newValue: 'closes', + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(pushed), + }), + ], + }), ]); - await userActionService.postUserActions({ - client, - actions: [ - buildCaseUserActionItem({ - action: 'push-to-service', - actionAt: pushedDate, - actionBy: pushedBy, - caseId, - fields: ['pushed'], - newValue: JSON.stringify(pushed), - }), - ], - }); - return response.ok({ body: CaseResponseRt.encode( flattenCaseSavedObject( diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index c445374c37adc..004558983b069 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -62,6 +62,9 @@ export const caseSavedObjectType: SavedObjectsType = { full_name: { type: 'keyword', }, + email: { + type: 'keyword', + }, }, }, connector_id: { diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 01d55e993ad8a..42f691d929a99 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -18,7 +18,13 @@ import { } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; -import { CaseAttributes, CommentAttributes, SavedObjectFindOptions, User } from '../../common/api'; +import { + CaseAttributes, + CommentAttributes, + SavedObjectFindOptions, + User, + CasePushedData, +} from '../../common/api'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -65,7 +71,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; From 3079c9143fd59aa7a1a9f1d120e4079d5f4459e3 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 09:51:37 -0400 Subject: [PATCH 12/17] review about client discrepency --- .../server/routes/api/cases/comments/delete_all_comments.ts | 2 +- .../case/server/routes/api/cases/comments/delete_comment.ts | 2 +- .../case/server/routes/api/cases/comments/find_comments.ts | 5 +++-- .../case/server/routes/api/cases/comments/get_all_comment.ts | 3 ++- .../case/server/routes/api/cases/comments/patch_comment.ts | 4 ++-- .../case/server/routes/api/cases/comments/post_comment.ts | 2 +- x-pack/plugins/case/server/routes/api/cases/get_case.ts | 5 +++-- x-pack/plugins/case/server/routes/api/cases/push_case.ts | 2 +- .../case/server/routes/api/cases/reporters/get_reporters.ts | 3 ++- .../case/server/routes/api/cases/status/get_status.ts | 5 +++-- x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts | 3 ++- .../routes/api/cases/user_actions/get_all_user_actions.ts | 3 ++- 12 files changed, 23 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index a1279ecd8247f..941ac90f2e90e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -27,7 +27,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); await Promise.all( diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 8d390ddb7ed17..44e57fc809e04 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -30,7 +30,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }: const deleteDate = new Date().toISOString(); const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, + client, commentId: request.params.comment_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index dcf70d0d9819c..92da64cebee74 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -32,6 +32,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( SavedObjectFindOptionsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) @@ -39,7 +40,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const args = query ? { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, options: { ...query, @@ -47,7 +48,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, } : { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 65f2de7125236..1500039eb2cc2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -22,8 +22,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index 2299591884354..c67ad1bdaea71 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -36,7 +36,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: ); const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, + client, commentId: query.id, }); @@ -60,7 +60,7 @@ export function initPatchCommentApi({ caseService, router, userActionService }: const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDate = new Date().toISOString(); const updatedComment = await caseService.patchComment({ - client: context.core.savedObjects.client, + client, commentId: query.id, updatedAttributes: { comment: query.comment, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index c0a321ab7b86a..2410505872a3a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -41,7 +41,7 @@ export function initPostCommentApi({ caseService, router, userActionService }: R ); const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1415513bca346..e947118a39e8e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -25,10 +25,11 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); const theCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); @@ -37,7 +38,7 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { } const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index be1793e43cd44..462b7af0db336 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -45,7 +45,7 @@ export function initPushCaseUserActionApi({ const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }), caseConfigureService.find({ client }), diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 519bb198f5f9e..56862a96e0563 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -16,8 +16,9 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const reporters = await caseService.getReporters({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index b4fc90d702604..f7431729d398c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -18,8 +18,9 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const argsOpenCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, @@ -29,7 +30,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }; const argsClosedCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, diff --git a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index ca51f421f4f56..55e8fe2af128c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -15,8 +15,9 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const tags = await caseService.getTags({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: tags }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 911ca9ced3643..2d4f16e46d561 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -23,8 +23,9 @@ export function initGetAllUserActionsApi({ userActionService, router }: RouteDep }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const userActions = await userActionService.getUserActions({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); return response.ok({ From c14f1d48eedc69cf503c45740ae1f5c7b3246274 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 13:49:09 -0400 Subject: [PATCH 13/17] clean up + review --- .../siem/public/containers/case/api.ts | 16 ++-- .../case/configure/use_configure.tsx | 8 +- .../public/containers/case/translations.ts | 7 ++ .../siem/public/containers/case/types.ts | 8 +- .../public/containers/case/use_get_case.tsx | 2 +- .../case/use_get_case_user_actions.tsx | 11 ++- .../case/use_post_push_to_service.tsx | 31 ++++--- .../siem/public/containers/case/utils.ts | 8 +- .../components/all_cases/__mock__/index.tsx | 10 +-- .../components/case_view/__mock__/index.tsx | 2 +- .../case/components/case_view/index.test.tsx | 1 + .../pages/case/components/case_view/index.tsx | 14 ++-- .../components/case_view/push_to_service.tsx | 16 ++-- .../case/components/configure_cases/index.tsx | 2 +- .../components/user_action_tree/helpers.tsx | 6 +- .../components/user_action_tree/index.tsx | 26 +++--- .../user_action_tree/user_action_title.tsx | 10 ++- x-pack/plugins/case/common/api/cases/case.ts | 42 +++++----- .../api/__fixtures__/mock_saved_objects.ts | 8 +- .../case/server/routes/api/cases/helpers.ts | 54 ++++++++++-- .../routes/api/cases/patch_cases.test.ts | 4 +- .../server/routes/api/cases/patch_cases.ts | 1 + .../case/server/routes/api/cases/push_case.ts | 20 +++-- .../plugins/case/server/routes/api/utils.ts | 2 +- .../case/server/saved_object_types/cases.ts | 6 +- x-pack/plugins/case/server/services/index.ts | 10 +-- .../server/services/user_actions/helpers.ts | 84 ++++++------------- 27 files changed, 218 insertions(+), 191 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5b57159cace5f..16ee294224bb9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -14,9 +14,9 @@ import { CommentResponse, User, CaseUserActionsResponse, - CasePushRequest, - PushCaseParams, - PushCaseResponse, + CaseExternalServiceRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; @@ -42,7 +42,7 @@ import { decodeCasesStatusResponse, decodeCommentResponse, decodeCaseUserActionsResponse, - decodePushCaseResponse, + decodeServiceConnectorCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -188,7 +188,7 @@ export const deleteCases = async (caseIds: string[]): Promise => { export const pushCase = async ( caseId: string, - push: CasePushRequest, + push: CaseExternalServiceRequest, signal: AbortSignal ): Promise => { const response = await KibanaServices.get().http.fetch( @@ -204,9 +204,9 @@ export const pushCase = async ( export const pushToService = async ( connectorId: string, - casePushParams: PushCaseParams, + casePushParams: ServiceConnectorCaseParams, signal: AbortSignal -): Promise => { +): Promise => { const response = await KibanaServices.get().http.fetch( `/api/action/${connectorId}/_execute`, { @@ -215,7 +215,7 @@ export const pushToService = async ( signal, } ); - return decodePushCaseResponse(response.data); + return decodeServiceConnectorCaseResponse(response.data); }; export const getActionLicense = async (signal: AbortSignal): Promise => { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 4d340ccf98605..a24f8303824c5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -25,12 +25,12 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string, newConnectorName?: string) => void; + setConnector: (newConnectorId: string, newConnectorName?: string) => void; setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ - setConnectorId, + setConnector, setClosureType, }: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); @@ -49,7 +49,7 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId, res.connectorName); + setConnector(res.connectorId, res.connectorName); if (setClosureType != null) { setClosureType(res.closureType); } @@ -99,7 +99,7 @@ export const useCaseConfigure = ({ ); if (!didCancel) { setPersistLoading(false); - setConnectorId(res.connectorId); + setConnector(res.connectorId); if (setClosureType) { setClosureType(res.closureType); } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts index 0c8b896e2b426..601db373f041e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch Tags', } ); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate( + 'xpack.siem.containers.case.pushToExterService', + { + defaultMessage: 'Successfully sent to ServiceNow', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index d3a88ba0b7b24..bbbb13788d53a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -29,9 +29,9 @@ export interface CaseUserActions { oldValue: string | null; } -export interface CasePush { - at: string; - by: string; +export interface CaseExternalService { + pushedAt: string; + pushedBy: string; connectorId: string; connectorName: string; externalId: string; @@ -46,7 +46,7 @@ export interface Case { createdAt: string; createdBy: ElasticUser; description: string; - pushed: CasePush | null; + externalService: CaseExternalService | null; status: string; tags: string[]; title: string; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index bbd4a84bccca5..02b41c9fc720f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -57,7 +57,7 @@ const initialData: Case = { username: '', }, description: '', - pushed: null, + externalService: null, status: '', tags: [], title: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx index cdc0f9cab5a6d..4c278bc038134 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -4,18 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash/fp'; +import { isEmpty, uniqBy } from 'lodash/fp'; import { useCallback, useEffect, useState } from 'react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { getCaseUserActions } from './api'; import * as i18n from './translations'; -import { CaseUserActions } from './types'; +import { CaseUserActions, ElasticUser } from './types'; interface CaseUserActionsState { caseUserActions: CaseUserActions[]; firstIndexPushToService: number; hasDataToPush: boolean; + participants: ElasticUser[]; isLoading: boolean; isError: boolean; lastIndexPushToService: number; @@ -28,6 +29,7 @@ const initialData: CaseUserActionsState = { hasDataToPush: false, isLoading: true, isError: false, + participants: [], }; interface UseGetCaseUserActions extends CaseUserActionsState { @@ -75,12 +77,16 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => // Attention Future developer // We are removing the first item because it will always be the creation of the case // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) + : []; const caseUserActions = !isEmpty(response) ? response.slice(1) : []; setCaseUserActionsState({ caseUserActions, ...getPushedInfo(caseUserActions), isLoading: false, isError: false, + participants, }); } } catch (error) { @@ -97,6 +103,7 @@ export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => hasDataToPush: false, isLoading: false, isError: true, + participants: [], }); } } diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx index bfc483cc93e1f..b6fb15f4fa083 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -6,22 +6,25 @@ import { useReducer, useCallback } from 'react'; -import { PushCaseResponse, PushCaseParams } from '../../../../../../plugins/case/common/api'; -import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { + ServiceConnectorCaseResponse, + ServiceConnectorCaseParams, +} from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters'; -import { pushToService, pushCase } from './api'; +import { getCase, pushToService, pushCase } from './api'; import * as i18n from './translations'; import { Case } from './types'; interface PushToServiceState { - serviceData: PushCaseResponse | null; + serviceData: ServiceConnectorCaseResponse | null; pushedCaseData: Case | null; isLoading: boolean; isError: boolean; } type Action = | { type: 'FETCH_INIT' } - | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: PushCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } | { type: 'FETCH_FAILURE' }; @@ -59,14 +62,14 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ }; interface PushToServiceRequest { + caseId: string; connectorId: string; connectorName: string; - caseToPush: Case; updateCase: (newCase: Case) => void; } interface UsePostPushToService extends PushToServiceState { - postPushToService: ({ caseToPush, connectorId, updateCase }: PushToServiceRequest) => void; + postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; } export const usePostPushToService = (): UsePostPushToService => { @@ -79,18 +82,19 @@ export const usePostPushToService = (): UsePostPushToService => { const [, dispatchToaster] = useStateToaster(); const postPushToService = useCallback( - async ({ caseToPush, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { let cancel = false; const abortCtrl = new AbortController(); try { dispatch({ type: 'FETCH_INIT' }); + const casePushData = await getCase(caseId); const responseService = await pushToService( connectorId, - formatServiceRequestData(caseToPush), + formatServiceRequestData(casePushData), abortCtrl.signal ); const responseCase = await pushCase( - caseToPush.id, + caseId, { connector_id: connectorId, connector_name: connectorName, @@ -104,6 +108,7 @@ export const usePostPushToService = (): UsePostPushToService => { dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); updateCase(responseCase); + displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster); } } catch (error) { if (!cancel) { @@ -126,14 +131,14 @@ export const usePostPushToService = (): UsePostPushToService => { return { ...state, postPushToService }; }; -const formatServiceRequestData = (myCase: Case): PushCaseParams => { +const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { const { id: caseId, createdAt, createdBy, comments, description, - pushed, + externalService, title, updatedAt, updatedBy, @@ -164,7 +169,7 @@ const formatServiceRequestData = (myCase: Case): PushCaseParams => { : null, })), description, - incidentId: pushed?.externalId ?? null, + incidentId: externalService?.externalId ?? null, title, updatedAt, updatedBy: diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 3831a7b1c6371..ce23ac6c440b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -25,8 +25,8 @@ import { CaseConfigureResponseRt, CaseUserActionsResponse, CaseUserActionsResponseRt, - PushCaseResponseRt, - PushCaseResponse, + ServiceConnectorCaseResponseRt, + ServiceConnectorCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -97,8 +97,8 @@ export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsR fold(throwErrors(createToasterPlainError), identity) ); -export const decodePushCaseResponse = (respPushCase?: PushCaseResponse) => +export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => pipe( - PushCaseResponseRt.decode(respPushCase), + ServiceConnectorCaseResponseRt.decode(respPushCase), fold(throwErrors(createToasterPlainError), identity) ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 3ca36d2dd9632..d4ec32dfd070b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -20,7 +20,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', @@ -37,7 +37,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', @@ -54,7 +54,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', @@ -71,7 +71,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', @@ -88,7 +88,7 @@ export const useGetCasesMockState: UseGetCasesState = { createdBy: { username: 'elastic' }, comments: [], description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'open', tags: ['phishing'], title: 'Uh oh', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index fbd461242bdb5..7aadea1a453a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -35,7 +35,7 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', - pushed: null, + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3c95ba8a4f0bf..18cc33d8a6d4d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -82,6 +82,7 @@ describe('CaseView ', () => { isLoading: false, isError: false, lastIndexPushToService: -1, + participants: [data.createdBy], }; const defaultUsePushToServiceMock = { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index dd6917b2f3806..742921cb9f69e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -15,7 +15,6 @@ import { import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { uniqBy } from 'lodash/fp'; import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -70,6 +69,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => hasDataToPush, isLoading: isLoadingUserActions, lastIndexPushToService, + participants, } = useGetCaseUserActions(caseId); const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( caseId, @@ -132,7 +132,8 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => ); const { pushButton, pushCallouts } = usePushToService({ - caseData, + caseId: caseData.id, + caseStatus: caseData.status, isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, updateCase: handleUpdateCase, }); @@ -144,10 +145,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => [onUpdateField] ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); - const participants = useMemo( - () => uniqBy('actionBy.username', caseUserActions).map(cau => cau.actionBy), - [caseUserActions] - ); + const caseStatusData = useMemo( () => caseData.status === 'open' @@ -233,7 +231,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => onUpdateField={onUpdateField} /> - + (({ caseId, initialData }) => users={[caseData.createdBy]} /> void; } @@ -32,7 +33,8 @@ interface ReturnUsePushToService { } export const usePushToService = ({ - caseData, + caseId, + caseStatus, updateCase, isNew, }: UsePushToService): ReturnUsePushToService => { @@ -45,7 +47,7 @@ export const usePushToService = ({ }, []); const { loading: loadingCaseConfigure } = useCaseConfigure({ - setConnectorId: handleSetConnector, + setConnector: handleSetConnector, }); const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); @@ -53,16 +55,16 @@ export const usePushToService = ({ const handlePushToService = useCallback(() => { if (connector != null) { postPushToService({ - caseToPush: caseData, + caseId, ...connector, updateCase, }); } - }, [caseData, connector, postPushToService, updateCase]); + }, [caseId, connector, postPushToService, updateCase]); const errorsMsg = useMemo(() => { let errors: Array<{ title: string; description: string }> = []; - if (caseData.status === 'closed') { + if (caseStatus === 'closed') { errors = [ ...errors, { @@ -99,7 +101,7 @@ export const usePushToService = ({ ]; } return errors; - }, [actionLicense, caseData, connector, loadingCaseConfigure, loadingLicense]); + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index 8a6d8a40e560a..fb4d91492c1d4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => { }, []); const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnectorId, + setConnector: setConnectorId, setClosureType, }); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx index 2a7197b2c635b..008f4d7048f56 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -7,7 +7,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; -import { CasePushedData } from '../../../../../../../../plugins/case/common/api'; +import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api'; import { CaseUserActions } from '../../../../containers/case/types'; import * as i18n from '../case_view/translations'; @@ -59,14 +59,14 @@ const getPushedServiceLabelTitle = ( firstIndexPushToService: number, index: number ) => { - const pushedVal = JSON.parse(action.newValue ?? '') as CasePushedData; + const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; return ( {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} - + {pushedVal?.connector_name} {pushedVal?.external_title} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index e41e14a6cc1f1..0a007ebf1e320 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -34,8 +34,8 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` margin-bottom: 8px; `; -const DescriptionId = 'description'; -const NewId = 'newComment'; +const DESCRIPTION_ID = 'description'; +const NEW_ID = 'newComment'; export const UserActionTree = React.memo( ({ @@ -114,12 +114,12 @@ export const UserActionTree = React.memo( const MarkdownDescription = useMemo( () => ( { - handleManageMarkdownEditId(DescriptionId); - onUpdateField(DescriptionId, content); + handleManageMarkdownEditId(DESCRIPTION_ID); + onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} /> @@ -132,7 +132,7 @@ export const UserActionTree = React.memo( ), @@ -152,14 +152,14 @@ export const UserActionTree = React.memo( <> @@ -231,7 +231,7 @@ export const UserActionTree = React.memo( } return null; })} - {(isLoadingUserActions || isLoadingIds.includes(NewId)) && ( + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( @@ -240,9 +240,9 @@ export const UserActionTree = React.memo( )} { - copy(`${window.location.origin}${window.location.pathname}#case/${caseId}/${id}${urlSearch}`, { - debug: true, - }); + copy( + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, + { + debug: true, + } + ); }, [caseId, id, urlSearch]); const handleMoveToLink = useCallback(() => { diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 627c3d2b04608..ee244dd205113 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -22,7 +22,7 @@ const CaseBasicRt = rt.type({ title: rt.string, }); -const CasePushBasicRt = rt.type({ +const CaseExternalServiceBasicRt = rt.type({ connector_id: rt.string, connector_name: rt.string, external_id: rt.string, @@ -30,12 +30,12 @@ const CasePushBasicRt = rt.type({ external_url: rt.string, }); -const CasePushedBasicRt = rt.union([ +const CaseFullExternalServiceRt = rt.union([ rt.intersection([ - CasePushBasicRt, + CaseExternalServiceBasicRt, rt.type({ - at: rt.string, - by: UserRT, + pushed_at: rt.string, + pushed_by: UserRT, }), ]), rt.null, @@ -48,7 +48,7 @@ export const CaseAttributesRt = rt.intersection([ closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, - pushed: CasePushedBasicRt, + external_service: CaseFullExternalServiceRt, updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -56,7 +56,7 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; -export const CasePushRequestRt = CasePushBasicRt; +export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), @@ -109,37 +109,37 @@ export const CasesResponseRt = rt.array(CaseResponseRt); * so we redefine then so we can use/validate types */ -const PushCaseUserParams = rt.type({ +const ServiceConnectorUserParams = rt.type({ fullName: rt.union([rt.string, rt.null]), username: rt.string, }); -export const PushCommentParamsRt = rt.type({ +export const ServiceConnectorCommentParamsRt = rt.type({ commentId: rt.string, comment: rt.string, createdAt: rt.string, - createdBy: PushCaseUserParams, + createdBy: ServiceConnectorUserParams, updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([PushCaseUserParams, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), }); -export const PushCaseParamsRt = rt.intersection([ +export const ServiceConnectorCaseParamsRt = rt.intersection([ rt.type({ caseId: rt.string, createdAt: rt.string, - createdBy: PushCaseUserParams, + createdBy: ServiceConnectorUserParams, incidentId: rt.union([rt.string, rt.null]), title: rt.string, updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([PushCaseUserParams, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), }), rt.partial({ description: rt.string, - comments: rt.array(PushCommentParamsRt), + comments: rt.array(ServiceConnectorCommentParamsRt), }), ]); -export const PushCaseResponseRt = rt.intersection([ +export const ServiceConnectorCaseResponseRt = rt.intersection([ rt.type({ number: rt.string, incidentId: rt.string, @@ -163,8 +163,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; -export type CasePushRequest = rt.TypeOf; -export type PushCaseParams = rt.TypeOf; -export type PushCaseResponse = rt.TypeOf; -export type CasePushedData = rt.TypeOf; -export type PushCommentParams = rt.TypeOf; +export type CaseExternalServiceRequest = rt.TypeOf; +export type ServiceConnectorCaseParams = rt.TypeOf; +export type ServiceConnectorCaseResponse = rt.TypeOf; +export type CaseFullExternalService = rt.TypeOf; +export type ServiceConnectorCommentParams = rt.TypeOf; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 3ad8e0b23b936..03da50f886fd5 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -21,7 +21,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', - pushed: null, + external_service: null, title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], @@ -49,7 +49,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', - pushed: null, + external_service: null, title: 'Damaging Data Destruction Detected', status: 'open', tags: ['Data Destruction'], @@ -77,7 +77,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - pushed: null, + external_service: null, title: 'Another bad one', status: 'open', tags: ['LOLBins'], @@ -109,7 +109,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - pushed: null, + external_service: null, status: 'closed', title: 'Another bad one', tags: ['LOLBins'], diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 537568310a06f..747b5195da7ec 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -4,16 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, get } from 'lodash'; +import { get } from 'lodash'; import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; -export const isTwoArraysDifference = (origVal: unknown, updatedVal: unknown) => - origVal != null && - updatedVal != null && - Array.isArray(updatedVal) && - Array.isArray(origVal) && - difference(origVal, updatedVal).length !== 0; +interface CompareArrays { + addedItems: string[]; + deletedItems: string[]; +} +export const compareArrays = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArrays => { + const result: CompareArrays = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach(origVal => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach(updatedVal => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const isTwoArraysDifference = ( + originalValue: unknown, + updatedValue: unknown +): CompareArrays | null => { + if ( + originalValue != null && + updatedValue != null && + Array.isArray(updatedValue) && + Array.isArray(originalValue) + ) { + const compObj = compareArrays({ originalValue, updatedValue }); + if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { + return compObj; + } + } + return null; +}; export const getCaseToUpdate = ( currentCase: CaseAttributes, diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 3c3e111cfa901..ac1e67cec52bd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -57,7 +57,7 @@ describe('PATCH cases', () => { created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', - pushed: null, + external_service: null, status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', @@ -100,7 +100,7 @@ describe('PATCH cases', () => { created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', - pushed: null, + external_service: null, status: 'open', tags: ['LOLBins'], title: 'Another bad one', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 62c41d872e2c8..3d0b7bc79f88b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -104,6 +104,7 @@ export function initPatchCasesApi({ caseService, router, userActionService }: Ro }; }), }); + const returnUpdatedCase = myCases.saved_objects .filter(myCase => updatedCases.saved_objects.some(updatedCase => updatedCase.id === myCase.id) diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 462b7af0db336..6ae3df180d9e4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -12,7 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; -import { CasePushRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; @@ -37,7 +37,7 @@ export function initPushCaseUserActionApi({ const client = context.core.savedObjects.client; const caseId = request.params.case_id; const query = pipe( - CasePushRequestRt.decode(request.body), + CaseExternalServiceRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const { username, full_name, email } = await caseService.getUser({ request, response }); @@ -76,9 +76,9 @@ export function initPushCaseUserActionApi({ }, }); - const pushed = { - at: pushedDate, - by: { username, full_name, email }, + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, ...query, }; @@ -90,9 +90,11 @@ export function initPushCaseUserActionApi({ ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' ? { status: 'closed', + closed_at: pushedDate, + closed_by: { email, full_name, username }, } : {}), - pushed, + external_service: externalService, updated_at: pushedDate, updated_by: { username, full_name, email }, }, @@ -121,8 +123,8 @@ export function initPushCaseUserActionApi({ actionAt: pushedDate, actionBy: { username, full_name, email }, caseId, - fields: [status], - newValue: 'closes', + fields: ['status'], + newValue: 'closed', oldValue: myCase.attributes.status, }), ] @@ -133,7 +135,7 @@ export function initPushCaseUserActionApi({ actionBy: { username, full_name, email }, caseId, fields: ['pushed'], - newValue: JSON.stringify(pushed), + newValue: JSON.stringify(externalService), }), ], }), diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index c026541787dca..9d90eb8ef4a6d 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -42,7 +42,7 @@ export const transformNewCase = ({ closed_by: null, created_at: createdDate, created_by: { email, full_name, username }, - pushed: null, + external_service: null, updated_at: null, updated_by: null, ...newCase, diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 004558983b069..a4c5dab0feeb7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -49,12 +49,12 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, - pushed: { + external_service: { properties: { - at: { + pushed_at: { type: 'date', }, - by: { + pushed_by: { properties: { username: { type: 'keyword', diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 42f691d929a99..09d726228d309 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -18,13 +18,7 @@ import { } from 'kibana/server'; import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; -import { - CaseAttributes, - CommentAttributes, - SavedObjectFindOptions, - User, - CasePushedData, -} from '../../common/api'; +import { CaseAttributes, CommentAttributes, SavedObjectFindOptions, User } from '../../common/api'; import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../saved_object_types'; import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; @@ -71,7 +65,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index d76d756bfa4f2..b5c0dc46facf8 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -117,34 +117,6 @@ export const buildCaseUserActionItem = ({ ], }); -interface CompareArray { - addedItems: string[]; - deletedItems: string[]; -} -const compareArray = ({ - originalValue, - updatedValue, -}: { - originalValue: string[]; - updatedValue: string[]; -}): CompareArray => { - const result: CompareArray = { - addedItems: [], - deletedItems: [], - }; - originalValue.forEach(origVal => { - if (!updatedValue.includes(origVal)) { - result.deletedItems = [...result.deletedItems, origVal]; - } - }); - updatedValue.forEach(updatedVal => { - if (!originalValue.includes(updatedVal)) { - result.addedItems = [...result.addedItems, updatedVal]; - } - }); - - return result; -}; const userActionFieldsAllowed: UserActionField = [ 'comment', 'description', @@ -173,37 +145,31 @@ export const buildCaseUserActions = ({ if (userActionFieldsAllowed.includes(field)) { const origValue = get(originalItem, ['attributes', field]); const updatedValue = get(updatedItem, ['attributes', field]); - if (isTwoArraysDifference(origValue, updatedValue)) { - const arrayDiff = compareArray({ - originalValue: origValue as string[], - updatedValue: updatedValue as string[], - }); - if (arrayDiff.addedItems.length > 0) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'add', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: arrayDiff.addedItems.join(', '), - }), - ]; - } - if (arrayDiff.deletedItems.length > 0) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'delete', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: arrayDiff.deletedItems.join(', '), - }), - ]; - } + const compareValues = isTwoArraysDifference(origValue, updatedValue); + if (compareValues != null && compareValues.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.addedItems.join(', '), + }), + ]; + } else if (compareValues != null && compareValues.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.deletedItems.join(', '), + }), + ]; } else if (origValue !== updatedValue) { userActions = [ ...userActions, From fb88c5ce2404c7a5de0ae5dfc7c8803a18a68524 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 14:16:12 -0400 Subject: [PATCH 14/17] merge issue --- .../public/pages/case/components/user_action_tree/index.tsx | 2 +- .../pages/case/components/user_action_tree/user_action_item.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 0a007ebf1e320..8b77186f76f77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -156,7 +156,7 @@ export const UserActionTree = React.memo( isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)} isLoading={isLoadingDescription} labelEditAction={i18n.EDIT_DESCRIPTION} - labelTitle={i18n.ADDED_DESCRIPTION} + labelTitle={<>{i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index dc51e6f3e83fb..10a7c56e2eb2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -146,7 +146,7 @@ export const UserActionItem = ({ createdAt={createdAt} id={id} isLoading={isLoading} - labelAction={labelEditAction} + labelEditAction={labelEditAction} labelTitle={labelTitle ?? <>} linkId={linkId} userName={userName} From e311752904b7589ab669fc50ea55ce49a650967a Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 16:00:41 -0400 Subject: [PATCH 15/17] update error msgs to info --- .../components/link_to/redirect_to_case.tsx | 7 +- .../siem/public/components/links/index.tsx | 28 +++++--- .../case/components/all_cases/index.test.tsx | 4 +- .../pages/case/components/all_cases/index.tsx | 20 +++--- .../components/case_view/push_to_service.tsx | 71 +++++++++++++++---- .../case/components/case_view/translations.ts | 38 ++++------ .../errors_push_service_callout/index.tsx | 19 ++--- .../translations.ts | 2 +- .../siem/public/pages/case/translations.ts | 2 +- .../plugins/siem/public/pages/case/utils.ts | 4 +- 10 files changed, 121 insertions(+), 74 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 3056b166c1153..20ba0b50f5126 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -31,6 +31,7 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; -export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; -export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`; +export const getCaseDetailsUrl = (detailName: string, search: string) => + `${baseCaseUrl}/${detailName}${search}`; +export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; +export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 04de0b1d5d3bf..935df9ad3361f 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,8 +23,10 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; +import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -89,20 +91,24 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ children, detailName, -}) => ( - - {children ? children : detailName} - -); +}) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return ( + + {children ? children : detailName} + + ); +}; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; -export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( - {children} -)); +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return {children}; +}); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 13869c79c45fd..bdcb87b483851 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -95,7 +95,9 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); + ).toEqual( + `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))` + ); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index e7e1e624ccba2..87a2ea888831a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -35,7 +35,9 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; - +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; @@ -43,10 +45,6 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; - -const CONFIGURE_CASES_URL = getConfigureCasesUrl(); -const CREATE_CASE_URL = getCreateCaseUrl(); const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -78,6 +76,7 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { + const urlSearch = useGetUrlSearch(navTabs.case); const { countClosedCases, countOpenCases, @@ -276,12 +275,12 @@ export const AllCases = React.memo(() => { /> - + {i18n.CONFIGURE_CASES_BUTTON} - + {i18n.CREATE_TITLE} @@ -342,7 +341,12 @@ export const AllCases = React.memo(() => { titleSize="xs" body={i18n.NO_CASES_BODY} actions={ - + {i18n.ADD_NEW_CASE} } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx index 1005636e4ff76..3cb4d06101731 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiToolTip } from '@elastic/eui'; +import { EuiButton, EuiLink, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useState, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { useCaseConfigure } from '../../../../containers/case/configure/use_configure'; import { Case } from '../../../../containers/case/types'; import { useGetActionLicense } from '../../../../containers/case/use_get_action_license'; import { usePostPushToService } from '../../../../containers/case/use_post_push_to_service'; - -import * as i18n from './translations'; +import { getConfigureCasesUrl } from '../../../../components/link_to'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { ErrorsPushServiceCallOut } from '../errors_push_service_callout'; +import * as i18n from './translations'; interface UsePushToService { caseId: string; @@ -38,6 +41,7 @@ export const usePushToService = ({ updateCase, isNew, }: UsePushToService): ReturnUsePushToService => { + const urlSearch = useGetUrlSearch(navTabs.case); const [connector, setConnector] = useState(null); const { isLoading, postPushToService } = usePostPushToService(); @@ -63,13 +67,25 @@ export const usePushToService = ({ }, [caseId, connector, postPushToService, updateCase]); const errorsMsg = useMemo(() => { - let errors: Array<{ title: string; description: string }> = []; - if (caseStatus === 'closed') { + let errors: Array<{ title: string; description: JSX.Element }> = []; + if (actionLicense != null && !actionLicense.enabledInLicense) { errors = [ ...errors, { - title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, - description: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_DESCRIPTION, + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + ), }, ]; } @@ -78,16 +94,33 @@ export const usePushToService = ({ ...errors, { title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, - description: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_DESCRIPTION, + description: ( + + {i18n.LINK_CONNECTOR_CONFIGURE} + + ), + }} + /> + ), }, ]; } - if (actionLicense != null && !actionLicense.enabledInLicense) { + if (caseStatus === 'closed') { errors = [ ...errors, { - title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, - description: i18n.PUSH_DISABLE_BY_LICENSE_DESCRIPTION, + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: ( + + ), }, ]; } @@ -96,12 +129,24 @@ export const usePushToService = ({ ...errors, { title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, - description: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_DESCRIPTION, + description: ( + + {'coming soon...'} + + ), + }} + /> + ), }, ]; } return errors; - }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense]); + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); const pushToServiceButton = useMemo( () => ( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index d869e962c44f6..beba80ccd934c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -117,55 +117,41 @@ export const UPDATE_PUSH_SERVICENOW = i18n.translate( export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', { - defaultMessage: 'Configure case', - } -); - -export const PUSH_DISABLE_BY_NO_CASE_CONFIG_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigDescription', - { - defaultMessage: 'You did not configure you case system', + defaultMessage: 'Configure external connector', } ); export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', { - defaultMessage: 'Case closed', - } -); - -export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedDescription', - { - defaultMessage: 'You cannot push a case who have been closed', + defaultMessage: 'Reopen the case', } ); export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', { - defaultMessage: 'Connector kibana config', + defaultMessage: 'Enable ServiceNow in Kibana configuration file', } ); -export const PUSH_DISABLE_BY_KIBANA_CONFIG_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.pushToServiceDisableByConfigDescription', +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', { - defaultMessage: 'ServiceNow connector have been disabled in kibana config', + defaultMessage: 'Upgrade to Elastic Platinum', } ); -export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( - 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', +export const LINK_CLOUD_DEPLOYMENT = i18n.translate( + 'xpack.siem.case.caseView.cloudDeploymentLink', { - defaultMessage: 'Elastic Stack subscriptions', + defaultMessage: 'cloud deployment', } ); -export const PUSH_DISABLE_BY_LICENSE_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.pushToServiceDisableByLicenseDescription', +export const LINK_CONNECTOR_CONFIGURE = i18n.translate( + 'xpack.siem.case.caseView.connectorConfigureLink', { - defaultMessage: 'ServiceNow is disabled because you do not have the right license', + defaultMessage: 'connector', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx index 37fea6f624536..15b50e4b4cd8d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui'; +import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; import React, { memo, useCallback, useState } from 'react'; import * as i18n from './translations'; interface ErrorsPushServiceCallOut { - errors: Array<{ title: string; description: string }>; + errors: Array<{ title: string; description: JSX.Element }>; } const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { @@ -18,12 +18,15 @@ const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); return showCallOut ? ( - - - - {i18n.DISMISS_CALLOUT} - - + <> + + + + {i18n.DISMISS_CALLOUT} + + + + ) : null; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts index cfd26632e6a36..57712e720f6d0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( 'xpack.siem.case.errorsPushServiceCallOutTitle', { - defaultMessage: 'You can not push to ServiceNow because of the errors below', + defaultMessage: 'To send cases to external systems, you need to:', } ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 4bb07053a9916..8f9d2087699f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -152,7 +152,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( ); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { - defaultMessage: 'Edit third-party connection', + defaultMessage: 'Edit external connection', }); export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index ccb3b71a476ec..3f2964b8cdd6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -21,7 +21,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(), + href: getCreateCaseUrl(''), }, ]; } else if (params.detailName != null) { @@ -29,7 +29,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName), + href: getCaseDetailsUrl(params.detailName, ''), }, ]; } From 28b7fa6946e5c94e53b1b1245fdae00012f82bff Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 16:34:00 -0400 Subject: [PATCH 16/17] add aria label + fix but on add/remove tags --- .../user_action_tree/translations.ts | 13 +++++ .../user_action_tree/user_action_title.tsx | 8 ++- .../server/services/user_actions/helpers.ts | 51 ++++++++++--------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts index 3958938e9c126..0ca6bcff513fc 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -6,6 +6,8 @@ import { i18n } from '@kbn/i18n'; +export * from '../case_view/translations'; + export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( 'xpack.siem.case.caseView.alreadyPushedToService', { @@ -19,3 +21,14 @@ export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( defaultMessage: 'Requires update to ServiceNow incident', } ); + +export const COPY_LINK_COMMENT = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { + defaultMessage: 'click to copy comment link', +}); + +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( + 'xpack.siem.case.caseView.moveToCommentAria', + { + defaultMessage: 'click to highlight the reference comment', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 5c67efb241a43..6ca81667d9712 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -15,9 +15,9 @@ import { useParams } from 'react-router-dom'; import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { navTabs } from '../../../home/home_navigations'; -import * as i18n from '../case_view/translations'; import { PropertyActions } from '../property_actions'; import { SiemPageName } from '../../../home/types'; +import * as i18n from './translations'; const MySpinner = styled(EuiLoadingSpinner)` .euiLoadingSpinner { @@ -124,8 +124,7 @@ export const UserActionTitle = ({ {!isEmpty(linkId) && ( @@ -133,8 +132,7 @@ export const UserActionTitle = ({ )} 0) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'add', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: compareValues.addedItems.join(', '), - }), - ]; - } else if (compareValues != null && compareValues.deletedItems.length > 0) { - userActions = [ - ...userActions, - buildCaseUserActionItem({ - action: 'delete', - actionAt: actionDate, - actionBy, - caseId: updatedItem.id, - fields: [field], - newValue: compareValues.deletedItems.join(', '), - }), - ]; + if (compareValues != null) { + if (compareValues.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.addedItems.join(', '), + }), + ]; + } + if (compareValues.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.deletedItems.join(', '), + }), + ]; + } } else if (origValue !== updatedValue) { userActions = [ ...userActions, From 87fca1ca69ac38175a40bcf3231de7706eaac635 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 17:42:17 -0400 Subject: [PATCH 17/17] fix i18n --- .../public/pages/case/components/case_view/push_to_service.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx index 3cb4d06101731..944302c1940ee 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx @@ -131,7 +131,7 @@ export const usePushToService = ({ title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, description: (