[SIEM][CASE] Persist callout when dismissed (#68372)
This commit is contained in:
parent
e4043b736b
commit
6808903d57
|
@ -13,7 +13,8 @@
|
|||
"test:generate": "ts-node --project scripts/endpoint/cli_tsconfig.json scripts/endpoint/resolver_generator.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.110"
|
||||
"@types/lodash": "^4.14.110",
|
||||
"@types/md5": "^2.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/rbush": "^3.0.0",
|
||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
export const NO_WRITE_ALERTS_CALLOUT_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutTitle',
|
||||
{
|
||||
defaultMessage: 'Alerts index permissions required',
|
||||
defaultMessage: 'You cannot change alert states',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -17,7 +17,7 @@ export const NO_WRITE_ALERTS_CALLOUT_MSG = i18n.translate(
|
|||
'xpack.securitySolution.detectionEngine.noWriteAlertsCallOutMsg',
|
||||
{
|
||||
defaultMessage:
|
||||
'You are currently missing the required permissions to update alerts. Please contact your administrator for further assistance.',
|
||||
'You only have permissions to view alerts. If you need to update alert states (open or close alerts), contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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 React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { CallOut, CallOutProps } from './callout';
|
||||
|
||||
describe('Callout', () => {
|
||||
const defaultProps: CallOutProps = {
|
||||
id: 'md5-hex',
|
||||
type: 'primary',
|
||||
title: 'a tittle',
|
||||
messages: [
|
||||
{
|
||||
id: 'generic-error',
|
||||
title: 'message-one',
|
||||
description: <p>{'error'}</p>,
|
||||
},
|
||||
],
|
||||
showCallOut: true,
|
||||
handleDismissCallout: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('It renders the callout', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeTruthy();
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeTruthy();
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the callout', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} showCallOut={false} />);
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not shows any messages when the list is empty', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - primary', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--primary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - success', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'success'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--secondary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - warning', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'warning'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--warning')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - danger', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'danger'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--danger')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dismiss the callout correctly', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
expect(defaultProps.handleDismissCallout).toHaveBeenCalledWith('md5-hex', 'primary');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 { isEmpty } from 'lodash/fp';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
|
||||
import { ErrorMessage } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export interface CallOutProps {
|
||||
id: string;
|
||||
type: NonNullable<ErrorMessage['errorType']>;
|
||||
title: string;
|
||||
messages: ErrorMessage[];
|
||||
showCallOut: boolean;
|
||||
handleDismissCallout: (id: string, type: NonNullable<ErrorMessage['errorType']>) => void;
|
||||
}
|
||||
|
||||
const CallOutComponent = ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
messages,
|
||||
showCallOut,
|
||||
handleDismissCallout,
|
||||
}: CallOutProps) => {
|
||||
const handleCallOut = useCallback(() => handleDismissCallout(id, type), [
|
||||
handleDismissCallout,
|
||||
id,
|
||||
type,
|
||||
]);
|
||||
|
||||
return showCallOut ? (
|
||||
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
|
||||
{!isEmpty(messages) && (
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
)}
|
||||
<EuiButton
|
||||
data-test-subj={`callout-dismiss-${id}`}
|
||||
color={type === 'success' ? 'secondary' : type}
|
||||
onClick={handleCallOut}
|
||||
>
|
||||
{i18n.DISMISS_CALLOUT}
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const CallOut = memo(CallOutComponent);
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 md5 from 'md5';
|
||||
import { createCalloutId } from './helpers';
|
||||
|
||||
describe('createCalloutId', () => {
|
||||
it('creates id correctly with one id', () => {
|
||||
const digest = md5('one');
|
||||
const id = createCalloutId(['one']);
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
|
||||
it('creates id correctly with multiples ids', () => {
|
||||
const digest = md5('one|two|three');
|
||||
const id = createCalloutId(['one', 'two', 'three']);
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
|
||||
it('creates id correctly with multiples ids and delimiter', () => {
|
||||
const digest = md5('one,two,three');
|
||||
const id = createCalloutId(['one', 'two', 'three'], ',');
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
});
|
|
@ -3,10 +3,18 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import md5 from 'md5';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { ErrorMessage } from './types';
|
||||
|
||||
export const savedObjectReadOnly = {
|
||||
export const savedObjectReadOnlyErrorMessage: ErrorMessage = {
|
||||
id: 'read-only-privileges-error',
|
||||
title: i18n.READ_ONLY_SAVED_OBJECT_TITLE,
|
||||
description: i18n.READ_ONLY_SAVED_OBJECT_MSG,
|
||||
description: <>{i18n.READ_ONLY_SAVED_OBJECT_MSG}</>,
|
||||
errorType: 'warning',
|
||||
};
|
||||
|
||||
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
|
||||
md5(ids.join(delimiter));
|
||||
|
|
|
@ -7,104 +7,210 @@
|
|||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { CaseCallOut } from '.';
|
||||
import { useMessagesStorage } from '../../../common/containers/local_storage/use_messages_storage';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { createCalloutId } from './helpers';
|
||||
import { CaseCallOut, CaseCallOutProps } from '.';
|
||||
|
||||
const defaultProps = {
|
||||
title: 'hey title',
|
||||
jest.mock('../../../common/containers/local_storage/use_messages_storage');
|
||||
|
||||
const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock;
|
||||
const securityLocalStorageMock = {
|
||||
getMessages: jest.fn(() => []),
|
||||
addMessage: jest.fn(),
|
||||
};
|
||||
|
||||
describe('CaseCallOut ', () => {
|
||||
it('Renders single message callout', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
message: 'we have one message',
|
||||
};
|
||||
|
||||
const wrapper = mount(<CaseCallOut {...props} />);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeTruthy();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useSecurityLocalStorageMock.mockImplementation(() => securityLocalStorageMock);
|
||||
});
|
||||
|
||||
it('Renders multi message callout', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
it('renders a callout correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ ...defaultProps, description: <p>{'we have two messages'}</p> },
|
||||
{ ...defaultProps, description: <p>{'for real'}</p> },
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
{ id: 'message-two', title: 'title', description: <p>{'for real'}</p> },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(<CaseCallOut {...props} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-message-primary"]`).last().exists()).toBeFalsy();
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="callout-messages-primary"]`).last().exists()
|
||||
).toBeTruthy();
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one', 'message-two']);
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-${id}"]`).last().exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('it shows the correct type of callouts', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
it('groups the messages correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
...defaultProps,
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger',
|
||||
errorType: 'danger',
|
||||
},
|
||||
{ ...defaultProps, description: <p>{'for real'}</p> },
|
||||
{ id: 'message-two', title: 'title two', description: <p>{'for real'}</p> },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(<CaseCallOut {...props} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-danger"]`).last().exists()).toBeTruthy();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const idDanger = createCalloutId(['message-one']);
|
||||
const idPrimary = createCalloutId(['message-two']);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="callout-messages-primary"]`).last().exists()
|
||||
wrapper.find(`[data-test-subj="case-callout-${idPrimary}"]`).last().exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="case-callout-${idDanger}"]`).last().exists()
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('it applies the correct color to button', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
it('dismisses the callout correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeTruthy();
|
||||
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('persist the callout of type primary when dismissed', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith('case');
|
||||
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
|
||||
expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith('case', id);
|
||||
});
|
||||
|
||||
it('do not show the callout if is in the localStorage', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
|
||||
useSecurityLocalStorageMock.mockImplementation(() => ({
|
||||
...securityLocalStorageMock,
|
||||
getMessages: jest.fn(() => [id]),
|
||||
}));
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('do not persist a callout of type danger', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
...defaultProps,
|
||||
description: <p>{'one'}</p>,
|
||||
errorType: 'danger' as 'primary' | 'success' | 'warning' | 'danger',
|
||||
},
|
||||
{
|
||||
...defaultProps,
|
||||
description: <p>{'two'}</p>,
|
||||
errorType: 'success' as 'primary' | 'success' | 'warning' | 'danger',
|
||||
},
|
||||
{
|
||||
...defaultProps,
|
||||
description: <p>{'three'}</p>,
|
||||
errorType: 'primary' as 'primary' | 'success' | 'warning' | 'danger',
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'danger',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(<CaseCallOut {...props} />);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-danger"]`).first().prop('color')).toBe(
|
||||
'danger'
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-success"]`).first().prop('color')).toBe(
|
||||
'secondary'
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).first().prop('color')).toBe(
|
||||
'primary'
|
||||
);
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Dismisses callout', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
message: 'we have one message',
|
||||
it('do not persist a callout of type warning', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'warning',
|
||||
},
|
||||
],
|
||||
};
|
||||
const wrapper = mount(<CaseCallOut {...props} />);
|
||||
expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeTruthy();
|
||||
wrapper.find(`[data-test-subj="callout-dismiss-primary"]`).last().simulate('click');
|
||||
expect(wrapper.find(`[data-test-subj="case-call-out-primary"]`).exists()).toBeFalsy();
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('do not persist a callout of type success', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'success',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<CaseCallOut {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,79 +4,99 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useState, useMemo } from 'react';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { useMessagesStorage } from '../../../common/containers/local_storage/use_messages_storage';
|
||||
import { CallOut } from './callout';
|
||||
import { ErrorMessage } from './types';
|
||||
import { createCalloutId } from './helpers';
|
||||
|
||||
export * from './helpers';
|
||||
|
||||
interface ErrorMessage {
|
||||
export interface CaseCallOutProps {
|
||||
title: string;
|
||||
description: JSX.Element;
|
||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
interface CaseCallOutProps {
|
||||
title: string;
|
||||
message?: string;
|
||||
messages?: ErrorMessage[];
|
||||
}
|
||||
|
||||
const CaseCallOutComponent = ({ title, message, messages }: CaseCallOutProps) => {
|
||||
const [showCallOut, setShowCallOut] = useState(true);
|
||||
const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]);
|
||||
let callOutMessages = messages ?? [];
|
||||
type GroupByTypeMessages = {
|
||||
[key in NonNullable<ErrorMessage['errorType']>]: {
|
||||
messagesId: string[];
|
||||
messages: ErrorMessage[];
|
||||
};
|
||||
};
|
||||
|
||||
if (message) {
|
||||
callOutMessages = [
|
||||
...callOutMessages,
|
||||
{
|
||||
title: '',
|
||||
description: <p data-test-subj="callout-message-primary">{message}</p>,
|
||||
errorType: 'primary',
|
||||
},
|
||||
];
|
||||
interface CalloutVisibility {
|
||||
[index: string]: boolean;
|
||||
}
|
||||
|
||||
const CaseCallOutComponent = ({ title, messages = [] }: CaseCallOutProps) => {
|
||||
const { getMessages, addMessage } = useMessagesStorage();
|
||||
|
||||
const caseMessages = useMemo(() => getMessages('case'), [getMessages]);
|
||||
const dismissedCallouts = useMemo(
|
||||
() =>
|
||||
caseMessages.reduce<CalloutVisibility>(
|
||||
(acc, id) => ({
|
||||
...acc,
|
||||
[id]: false,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
[caseMessages]
|
||||
);
|
||||
|
||||
const [calloutVisibility, setCalloutVisibility] = useState(dismissedCallouts);
|
||||
const handleCallOut = useCallback(
|
||||
(id, type) => {
|
||||
setCalloutVisibility((prevState) => ({ ...prevState, [id]: false }));
|
||||
if (type === 'primary') {
|
||||
addMessage('case', id);
|
||||
}
|
||||
},
|
||||
[setCalloutVisibility, addMessage]
|
||||
);
|
||||
|
||||
const groupedErrorMessages = callOutMessages.reduce((acc, currentMessage: ErrorMessage) => {
|
||||
const key = currentMessage.errorType == null ? 'primary' : currentMessage.errorType;
|
||||
const groupedByTypeErrorMessages = useMemo(
|
||||
() =>
|
||||
messages.reduce<GroupByTypeMessages>(
|
||||
(acc: GroupByTypeMessages, currentMessage: ErrorMessage) => {
|
||||
const type = currentMessage.errorType == null ? 'primary' : currentMessage.errorType;
|
||||
return {
|
||||
...acc,
|
||||
[key]: [...(acc[key] || []), currentMessage],
|
||||
[type]: {
|
||||
messagesId: [...(acc[type]?.messagesId ?? []), currentMessage.id],
|
||||
messages: [...(acc[type]?.messages ?? []), currentMessage],
|
||||
},
|
||||
};
|
||||
}, {} as { [key in NonNullable<ErrorMessage['errorType']>]: ErrorMessage[] });
|
||||
},
|
||||
{} as GroupByTypeMessages
|
||||
),
|
||||
[messages]
|
||||
);
|
||||
|
||||
return showCallOut ? (
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(groupedErrorMessages) as Array<keyof ErrorMessage['errorType']>).map((key) => (
|
||||
<React.Fragment key={key}>
|
||||
<EuiCallOut
|
||||
{(Object.keys(groupedByTypeErrorMessages) as Array<keyof ErrorMessage['errorType']>).map(
|
||||
(type: NonNullable<ErrorMessage['errorType']>) => {
|
||||
const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId);
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<CallOut
|
||||
id={id}
|
||||
type={type}
|
||||
title={title}
|
||||
color={key}
|
||||
iconType="gear"
|
||||
data-test-subj={`case-call-out-${key}`}
|
||||
>
|
||||
{!isEmpty(groupedErrorMessages[key]) && (
|
||||
<EuiDescriptionList
|
||||
data-test-subj={`callout-messages-${key}`}
|
||||
listItems={groupedErrorMessages[key]}
|
||||
messages={groupedByTypeErrorMessages[type].messages}
|
||||
showCallOut={calloutVisibility[id] ?? true}
|
||||
handleDismissCallout={handleCallOut}
|
||||
/>
|
||||
)}
|
||||
<EuiButton
|
||||
data-test-subj={`callout-dismiss-${key}`}
|
||||
color={key === 'success' ? 'secondary' : key}
|
||||
onClick={handleCallOut}
|
||||
>
|
||||
{i18n.DISMISS_CALLOUT}
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</React.Fragment>
|
||||
))}
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
) : null;
|
||||
);
|
||||
};
|
||||
|
||||
export const CaseCallOut = memo(CaseCallOutComponent);
|
||||
|
|
|
@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
export const READ_ONLY_SAVED_OBJECT_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.case.readOnlySavedObjectTitle',
|
||||
{
|
||||
defaultMessage: 'You have read-only feature privileges',
|
||||
defaultMessage: 'You cannot open new or update existing cases',
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -17,7 +17,7 @@ export const READ_ONLY_SAVED_OBJECT_MSG = i18n.translate(
|
|||
'xpack.securitySolution.case.readOnlySavedObjectDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'You are only allowed to view cases. If you need to open and update cases, contact your Kibana administrator',
|
||||
'You only have permissions to view cases. If you need to open and update cases, contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface ErrorMessage {
|
||||
id: string;
|
||||
title: string;
|
||||
description: JSX.Element;
|
||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
||||
}
|
|
@ -10,8 +10,10 @@ import React from 'react';
|
|||
|
||||
import * as i18n from './translations';
|
||||
import { ActionLicense } from '../../containers/types';
|
||||
import { ErrorMessage } from '../callout/types';
|
||||
|
||||
export const getLicenseError = () => ({
|
||||
id: 'license-error',
|
||||
title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
@ -29,6 +31,7 @@ export const getLicenseError = () => ({
|
|||
});
|
||||
|
||||
export const getKibanaConfigError = () => ({
|
||||
id: 'kibana-config-error',
|
||||
title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
@ -45,10 +48,8 @@ export const getKibanaConfigError = () => ({
|
|||
),
|
||||
});
|
||||
|
||||
export const getActionLicenseError = (
|
||||
actionLicense: ActionLicense | null
|
||||
): Array<{ title: string; description: JSX.Element }> => {
|
||||
let errors: Array<{ title: string; description: JSX.Element }> = [];
|
||||
export const getActionLicenseError = (actionLicense: ActionLicense | null): ErrorMessage[] => {
|
||||
let errors: ErrorMessage[] = [];
|
||||
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
||||
errors = [...errors, getLicenseError()];
|
||||
}
|
||||
|
|
|
@ -10,9 +10,7 @@ import { usePushToService, ReturnUsePushToService, UsePushToService } from '.';
|
|||
import { TestProviders } from '../../../common/mock';
|
||||
import { usePostPushToService } from '../../containers/use_post_push_to_service';
|
||||
import { basicPush, actionLicenses } from '../../containers/mock';
|
||||
import * as i18n from './translations';
|
||||
import { useGetActionLicense } from '../../containers/use_get_action_license';
|
||||
import { getKibanaConfigError, getLicenseError } from './helpers';
|
||||
import { connectorsMock } from '../../containers/configure/mock';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
|
@ -110,7 +108,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(getLicenseError().title);
|
||||
expect(errorsMsg[0].id).toEqual('license-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -132,7 +130,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(getKibanaConfigError().title);
|
||||
expect(errorsMsg[0].id).toEqual('kibana-config-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -152,7 +150,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE);
|
||||
expect(errorsMsg[0].id).toEqual('connector-missing-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -171,7 +169,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE);
|
||||
expect(errorsMsg[0].id).toEqual('connector-not-selected-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -191,7 +189,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE);
|
||||
expect(errorsMsg[0].id).toEqual('connector-deleted-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -212,7 +210,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE);
|
||||
expect(errorsMsg[0].id).toEqual('connector-deleted-error');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -231,7 +229,7 @@ describe('usePushToService', () => {
|
|||
await waitForNextUpdate();
|
||||
const errorsMsg = result.current.pushCallouts?.props.messages;
|
||||
expect(errorsMsg).toHaveLength(1);
|
||||
expect(errorsMsg[0].title).toEqual(i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE);
|
||||
expect(errorsMsg[0].id).toEqual('closed-case-push-error');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Connector } from '../../../../../case/common/api/cases';
|
|||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||
import { LinkAnchor } from '../../../common/components/links';
|
||||
import { SecurityPageName } from '../../../app/types';
|
||||
import { ErrorMessage } from '../callout/types';
|
||||
|
||||
export interface UsePushToService {
|
||||
caseId: string;
|
||||
|
@ -76,11 +77,7 @@ export const usePushToService = ({
|
|||
);
|
||||
|
||||
const errorsMsg = useMemo(() => {
|
||||
let errors: Array<{
|
||||
title: string;
|
||||
description: JSX.Element;
|
||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
||||
}> = [];
|
||||
let errors: ErrorMessage[] = [];
|
||||
if (actionLicense != null && !actionLicense.enabledInLicense) {
|
||||
errors = [...errors, getLicenseError()];
|
||||
}
|
||||
|
@ -88,6 +85,7 @@ export const usePushToService = ({
|
|||
errors = [
|
||||
...errors,
|
||||
{
|
||||
id: 'connector-missing-error',
|
||||
title: i18n.PUSH_DISABLE_BY_NO_CONFIG_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
@ -112,6 +110,7 @@ export const usePushToService = ({
|
|||
errors = [
|
||||
...errors,
|
||||
{
|
||||
id: 'connector-not-selected-error',
|
||||
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
@ -125,6 +124,7 @@ export const usePushToService = ({
|
|||
errors = [
|
||||
...errors,
|
||||
{
|
||||
id: 'connector-deleted-error',
|
||||
title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
@ -140,6 +140,7 @@ export const usePushToService = ({
|
|||
errors = [
|
||||
...errors,
|
||||
{
|
||||
id: 'closed-case-push-error',
|
||||
title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE,
|
||||
description: (
|
||||
<FormattedMessage
|
||||
|
|
|
@ -11,7 +11,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana';
|
|||
import { SpyRoute } from '../../common/utils/route/spy_routes';
|
||||
import { AllCases } from '../components/all_cases';
|
||||
|
||||
import { savedObjectReadOnly, CaseCallOut } from '../components/callout';
|
||||
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
import { CaseSavedObjectNoPermissions } from './saved_object_no_permissions';
|
||||
import { SecurityPageName } from '../../app/types';
|
||||
|
||||
|
@ -23,8 +23,8 @@ export const CasesPage = React.memo(() => {
|
|||
<WrapperPage>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={savedObjectReadOnly.title}
|
||||
message={savedObjectReadOnly.description}
|
||||
title={savedObjectReadOnlyErrorMessage.title}
|
||||
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
|
||||
/>
|
||||
)}
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
|
|
|
@ -15,7 +15,7 @@ import { useGetUserSavedObjectPermissions } from '../../common/lib/kibana';
|
|||
import { getCaseUrl } from '../../common/components/link_to';
|
||||
import { navTabs } from '../../app/home/home_navigations';
|
||||
import { CaseView } from '../components/case_view';
|
||||
import { savedObjectReadOnly, CaseCallOut } from '../components/callout';
|
||||
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
|
||||
export const CaseDetailsPage = React.memo(() => {
|
||||
const history = useHistory();
|
||||
|
@ -33,8 +33,8 @@ export const CaseDetailsPage = React.memo(() => {
|
|||
<WrapperPage noPadding>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={savedObjectReadOnly.title}
|
||||
message={savedObjectReadOnly.description}
|
||||
title={savedObjectReadOnlyErrorMessage.title}
|
||||
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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 { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { createUseKibanaMock } from '../../mock/kibana_react';
|
||||
import { useMessagesStorage, UseMessagesStorage } from './use_messages_storage';
|
||||
|
||||
jest.mock('../../lib/kibana');
|
||||
const useKibanaMock = useKibana as jest.Mock;
|
||||
|
||||
describe('useLocalStorage', () => {
|
||||
beforeEach(() => {
|
||||
const services = { ...createUseKibanaMock()().services };
|
||||
useKibanaMock.mockImplementation(() => ({ services }));
|
||||
services.storage.store.clear();
|
||||
});
|
||||
|
||||
it('should return an empty array when there is no messages', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
|
||||
useMessagesStorage()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const { getMessages } = result.current;
|
||||
expect(getMessages('case')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a message', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
|
||||
useMessagesStorage()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const { getMessages, addMessage } = result.current;
|
||||
addMessage('case', 'id-1');
|
||||
expect(getMessages('case')).toEqual(['id-1']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should add multiple messages', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
|
||||
useMessagesStorage()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const { getMessages, addMessage } = result.current;
|
||||
addMessage('case', 'id-1');
|
||||
addMessage('case', 'id-2');
|
||||
expect(getMessages('case')).toEqual(['id-1', 'id-2']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove a message', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
|
||||
useMessagesStorage()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const { getMessages, addMessage, removeMessage } = result.current;
|
||||
addMessage('case', 'id-1');
|
||||
addMessage('case', 'id-2');
|
||||
removeMessage('case', 'id-2');
|
||||
expect(getMessages('case')).toEqual(['id-1']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear all messages', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseMessagesStorage>(() =>
|
||||
useMessagesStorage()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
const { getMessages, addMessage, clearAllMessages } = result.current;
|
||||
addMessage('case', 'id-1');
|
||||
addMessage('case', 'id-2');
|
||||
clearAllMessages('case');
|
||||
expect(getMessages('case')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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 } from 'react';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
|
||||
export interface UseMessagesStorage {
|
||||
getMessages: (plugin: string) => string[];
|
||||
addMessage: (plugin: string, id: string) => void;
|
||||
removeMessage: (plugin: string, id: string) => void;
|
||||
clearAllMessages: (plugin: string) => void;
|
||||
}
|
||||
|
||||
export const useMessagesStorage = (): UseMessagesStorage => {
|
||||
const { storage } = useKibana().services;
|
||||
|
||||
const getMessages = useCallback(
|
||||
(plugin: string): string[] => storage.get(`${plugin}-messages`) ?? [],
|
||||
[storage]
|
||||
);
|
||||
|
||||
const addMessage = useCallback(
|
||||
(plugin: string, id: string) => {
|
||||
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
|
||||
storage.set(`${plugin}-messages`, [...pluginStorage, id]);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
|
||||
const removeMessage = useCallback(
|
||||
(plugin: string, id: string) => {
|
||||
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
|
||||
storage.set(`${plugin}-messages`, [...pluginStorage.filter((val: string) => val !== id)]);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
|
||||
const clearAllMessages = useCallback(
|
||||
(plugin: string): string[] => storage.remove(`${plugin}-messages`),
|
||||
[storage]
|
||||
);
|
||||
|
||||
return {
|
||||
getMessages,
|
||||
addMessage,
|
||||
clearAllMessages,
|
||||
removeMessage,
|
||||
};
|
||||
};
|
|
@ -26,6 +26,7 @@ import {
|
|||
DEFAULT_INDEX_PATTERN,
|
||||
} from '../../../common/constants';
|
||||
import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core';
|
||||
import { createSecuritySolutionStorageMock } from './mock_local_storage';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const mockUiSettings: Record<string, any> = {
|
||||
|
@ -74,6 +75,7 @@ export const createUseKibanaMock = () => {
|
|||
const core = createKibanaCoreStartMock();
|
||||
const plugins = createKibanaPluginsStartMock();
|
||||
const useUiSetting = createUseUiSettingMock();
|
||||
const { storage } = createSecuritySolutionStorageMock();
|
||||
|
||||
const services = {
|
||||
...core,
|
||||
|
@ -82,6 +84,7 @@ export const createUseKibanaMock = () => {
|
|||
...core.uiSettings,
|
||||
get: useUiSetting,
|
||||
},
|
||||
storage,
|
||||
};
|
||||
|
||||
return () => ({ services });
|
||||
|
|
|
@ -10,6 +10,6 @@ export const CALL_OUT_UNAUTHORIZED_MSG = i18n.translate(
|
|||
'xpack.securitySolution.timeline.callOut.unauthorized.message.description',
|
||||
{
|
||||
defaultMessage:
|
||||
'You require permission to auto-save timelines within the SIEM application, though you may continue to use the timeline to search and filter security events',
|
||||
'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -5375,6 +5375,13 @@
|
|||
dependencies:
|
||||
"@types/linkify-it" "*"
|
||||
|
||||
"@types/md5@^2.2.0":
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.2.0.tgz#cd82e16b95973f94bb03dee40c5b6be4a7fb7fb4"
|
||||
integrity sha512-JN8OVL/wiDlCWTPzplsgMPu0uE9Q6blwp68rYsfk2G8aokRUQ8XD9MEhZwihfAiQvoyE+m31m6i3GFXwYWomKQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/memoize-one@^4.1.0":
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369"
|
||||
|
|
Loading…
Reference in a new issue