[8.0] [Uptime] Disable 'Create Rule' button when user doesn't have uptime write permissions [#118404] (#120379) (#121163)

* [Uptime] Disable 'Create Rule' button when user doesn't have uptime write permissions [#118404] (#120379)

* [Uptime] Disable 'Create Rule' button when user doesn't have uptime write permissions [#118404]

Before this commit, users would be able to open the flyout to create an
alert and would end-up seeing an error toast when they tried to save it.

This commit will now disable the create alert button when the user
doesn't have permissions to write to Uptime. It will also display a
helpful tooltip.

* [Uptime] Disable "Enable Anomaly Alert" when users can't write to uptime [#118404]

This commit causes users not to be able to use the "Enable Anomaly
Alert" button within the popover in the monitors screen. That button
will now be disabled and contain an informative tooltip whenever users
don't have permissions to write to Uptime.

We've chosen to take this approach so that we don't have to modify the
component which deals with the alert creation, which belongs to another
team and that we plan on eventually replacing. Furthermore, this pattern
is already used in the logs app.

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

* add missing useKibana hook to ML Flyout Container for detecting uptime write permissions

Co-authored-by: Lucas F. da Costa <lucas@lucasfcosta.com>
Co-authored-by: Lucas Fernandes da Costa <lucas.costa@elastic.co>
This commit is contained in:
Kibana Machine 2021-12-16 07:35:17 -05:00 committed by GitHub
parent 742fbe969f
commit 06be872e20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 226 additions and 167 deletions

View file

@ -1,124 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Manage ML Job renders without errors 1`] = `
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<button
aria-label="You can enable anomaly detection job or if job is already there you can manage the job or alert."
class="euiButton euiButton--primary euiButton--small euiButton-isDisabled"
data-test-subj="uptimeManageMLJobBtn"
disabled=""
type="button"
>
<span
class="euiButtonContent euiButton__content"
>
<span
class="euiLoadingSpinner euiLoadingSpinner--medium euiButtonContent__spinner"
/>
<span
class="euiButton__text"
/>
</span>
</button>
</div>
</div>
`;
exports[`Manage ML Job shallow renders without errors 1`] = `
<ContextProvider
value={
Object {
"history": Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
},
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"match": Object {
"isExact": true,
"params": Object {},
"path": "/",
"url": "/",
},
"staticContext": undefined,
}
}
>
<ContextProvider
value={
Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<ManageMLJobComponent
hasMLJob={true}
onEnableJob={[MockFunction]}
onJobDelete={[MockFunction]}
/>
</ContextProvider>
</ContextProvider>
`;

View file

@ -30,6 +30,7 @@ import {
isAnomalyAlertDeleting,
} from '../../../state/alerts/alerts';
import { UptimeEditAlertFlyoutComponent } from '../../common/alerts/uptime_edit_alert_flyout';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
interface Props {
hasMLJob: boolean;
@ -38,6 +39,8 @@ interface Props {
}
export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Props) => {
const core = useKibana();
const [isPopOverOpen, setIsPopOverOpen] = useState(false);
const [isFlyoutOpen, setIsFlyoutOpen] = useState(false);
@ -82,6 +85,8 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro
</EuiButton>
);
const hasUptimeWrite = core.services.application?.capabilities.uptime?.save ?? false;
const panels = [
{
id: 0,
@ -110,6 +115,10 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro
name: labels.ENABLE_ANOMALY_ALERT,
'data-test-subj': 'uptimeEnableAnomalyAlertBtn',
icon: 'bell',
disabled: !hasUptimeWrite,
toolTipContent: !hasUptimeWrite
? labels.ENABLE_ANOMALY_NO_PERMISSIONS_TOOLTIP
: null,
onClick: () => {
dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY));
dispatch(setAlertFlyoutVisible(true));

View file

@ -23,13 +23,12 @@ import {
import { MLJobLink } from './ml_job_link';
import * as labels from './translations';
import { MLFlyoutView } from './ml_flyout';
import { ML_JOB_ID } from '../../../../common/constants';
import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts';
import { useGetUrlParams } from '../../../hooks';
import { getDynamicSettings } from '../../../state/actions/dynamic_settings';
import { useMonitorId } from '../../../hooks';
import { kibanaService } from '../../../state/kibana_service';
import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public';
import { toMountPoint, useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
interface Props {
@ -73,6 +72,7 @@ const showMLJobNotification = (
};
export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => {
const core = useKibana();
const dispatch = useDispatch();
const { data: hasMLJob, error } = useSelector(hasNewMLJobSelector);
const isMLJobCreating = useSelector(isMLJobCreatingSelector);
@ -113,14 +113,14 @@ export const MachineLearningFlyout: React.FC<Props> = ({ onClose }) => {
true,
hasMLJob.awaitingNodeAssignment
);
const loadMLJob = (jobId: string) =>
dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string }));
loadMLJob(ML_JOB_ID);
dispatch(getExistingMLJobAction.get({ monitorId: monitorId as string }));
refreshApp();
dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY));
dispatch(setAlertFlyoutVisible(true));
const hasUptimeWrite = core.services.application?.capabilities.uptime?.save ?? false;
if (hasUptimeWrite) {
dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY));
dispatch(setAlertFlyoutVisible(true));
}
} else {
showMLJobNotification(
monitorId as string,

View file

@ -6,35 +6,97 @@
*/
import React from 'react';
import { coreMock } from 'src/core/public/mocks';
import userEvent from '@testing-library/user-event';
import { ManageMLJobComponent } from './manage_ml_job';
import * as redux from 'react-redux';
import { renderWithRouter, shallowWithRouter } from '../../../lib';
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
import {
render,
makeUptimePermissionsCore,
forNearestButton,
} from '../../../lib/helper/rtl_helpers';
import * as labels from './translations';
const core = coreMock.createStart();
describe('Manage ML Job', () => {
it('shallow renders without errors', () => {
jest.spyOn(redux, 'useSelector').mockReturnValue(true);
jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn());
const makeMlCapabilities = (mlCapabilities?: Partial<{ canDeleteJob: boolean }>) => {
return {
ml: {
mlCapabilities: { data: { capabilities: { canDeleteJob: true, ...mlCapabilities } } },
},
};
};
const wrapper = shallowWithRouter(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />
);
expect(wrapper).toMatchSnapshot();
describe('when users have write access to uptime', () => {
it('enables the button to create alerts', () => {
const { getByText } = render(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />,
{
state: makeMlCapabilities(),
core: makeUptimePermissionsCore({ save: true }),
}
);
const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION);
expect(anomalyDetectionBtn).toBeInTheDocument();
userEvent.click(anomalyDetectionBtn as HTMLElement);
expect(forNearestButton(getByText)(labels.ENABLE_ANOMALY_ALERT)).toBeEnabled();
});
it('does not display an informative tooltip', async () => {
const { getByText, findByText } = render(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />,
{
state: makeMlCapabilities(),
core: makeUptimePermissionsCore({ save: true }),
}
);
const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION);
expect(anomalyDetectionBtn).toBeInTheDocument();
userEvent.click(anomalyDetectionBtn as HTMLElement);
userEvent.hover(getByText(labels.ENABLE_ANOMALY_ALERT));
await expect(() =>
findByText('You need write access to Uptime to create anomaly alerts.')
).rejects.toEqual(expect.anything());
});
});
it('renders without errors', () => {
jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn());
jest.spyOn(redux, 'useSelector').mockReturnValue(true);
describe("when users don't have write access to uptime", () => {
it('disables the button to create alerts', () => {
const { getByText } = render(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />,
{
state: makeMlCapabilities(),
core: makeUptimePermissionsCore({ save: false }),
}
);
const wrapper = renderWithRouter(
<KibanaContextProvider
services={{ ...core, triggersActionsUi: { getEditAlertFlyout: jest.fn() } }}
>
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />
</KibanaContextProvider>
);
expect(wrapper).toMatchSnapshot();
const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION);
expect(anomalyDetectionBtn).toBeInTheDocument();
userEvent.click(anomalyDetectionBtn as HTMLElement);
expect(forNearestButton(getByText)(labels.ENABLE_ANOMALY_ALERT)).toBeDisabled();
});
it('displays an informative tooltip', async () => {
const { getByText, findByText } = render(
<ManageMLJobComponent hasMLJob={true} onEnableJob={jest.fn()} onJobDelete={jest.fn()} />,
{
state: makeMlCapabilities(),
core: makeUptimePermissionsCore({ save: false }),
}
);
const anomalyDetectionBtn = forNearestButton(getByText)(labels.ANOMALY_DETECTION);
expect(anomalyDetectionBtn).toBeInTheDocument();
userEvent.click(anomalyDetectionBtn as HTMLElement);
userEvent.hover(getByText(labels.ENABLE_ANOMALY_ALERT));
expect(
await findByText('You need read-write access to Uptime to create anomaly alerts.')
).toBeInTheDocument();
});
});
});

View file

@ -105,6 +105,13 @@ export const ENABLE_ANOMALY_ALERT = i18n.translate(
}
);
export const ENABLE_ANOMALY_NO_PERMISSIONS_TOOLTIP = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.noPermissionsTooltip',
{
defaultMessage: 'You need read-write access to Uptime to create anomaly alerts.',
}
);
export const DISABLE_ANOMALY_ALERT = i18n.translate(
'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyAlert',
{

View file

@ -58,7 +58,6 @@ describe('<WaterfallMarkerTrend />', () => {
{
core: {
http: {
// @ts-expect-error incomplete implementation for testing purposes
basePath: {
get: () => BASE_PATH,
},

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import userEvent from '@testing-library/user-event';
import {
render,
forNearestButton,
makeUptimePermissionsCore,
} from '../../../lib/helper/rtl_helpers';
import { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button';
import { ToggleFlyoutTranslations } from './translations';
describe('ToggleAlertFlyoutButtonComponent', () => {
describe('when users have write access to uptime', () => {
it('enables the button to create a rule', () => {
const { getByText } = render(
<ToggleAlertFlyoutButtonComponent setAlertFlyoutVisible={jest.fn()} />,
{ core: makeUptimePermissionsCore({ save: true }) }
);
userEvent.click(getByText('Alerts and rules'));
expect(
forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel)
).toBeEnabled();
});
it("does not contain a tooltip explaining why the user can't create alerts", async () => {
const { getByText, findByText } = render(
<ToggleAlertFlyoutButtonComponent setAlertFlyoutVisible={jest.fn()} />,
{ core: makeUptimePermissionsCore({ save: true }) }
);
userEvent.click(getByText('Alerts and rules'));
userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel));
await expect(() =>
findByText('You need read-write access to Uptime to create alerts in this app.')
).rejects.toEqual(expect.anything());
});
});
describe("when users don't have write access to uptime", () => {
it('disables the button to create a rule', () => {
const { getByText } = render(
<ToggleAlertFlyoutButtonComponent setAlertFlyoutVisible={jest.fn()} />,
{ core: makeUptimePermissionsCore({ save: false }) }
);
userEvent.click(getByText('Alerts and rules'));
expect(
forNearestButton(getByText)(ToggleFlyoutTranslations.openAlertContextPanelLabel)
).toBeDisabled();
});
it("contains a tooltip explaining why users can't create rules", async () => {
const { getByText, findByText } = render(
<ToggleAlertFlyoutButtonComponent setAlertFlyoutVisible={jest.fn()} />,
{ core: makeUptimePermissionsCore({ save: false }) }
);
userEvent.click(getByText('Alerts and rules'));
userEvent.hover(getByText(ToggleFlyoutTranslations.openAlertContextPanelLabel));
expect(
await findByText('You need read-write access to Uptime to create alerts in this app.')
).toBeInTheDocument();
});
});
});

View file

@ -14,6 +14,7 @@ import {
EuiPopover,
} from '@elastic/eui';
import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
@ -29,12 +30,22 @@ type Props = ComponentProps & ToggleAlertFlyoutButtonProps;
const ALERT_CONTEXT_MAIN_PANEL_ID = 0;
const ALERT_CONTEXT_SELECT_TYPE_PANEL_ID = 1;
const noWritePermissionsTooltipContent = i18n.translate(
'xpack.uptime.alertDropdown.noWritePermissions',
{
defaultMessage: 'You need read-write access to Uptime to create alerts in this app.',
}
);
export const ToggleAlertFlyoutButtonComponent: React.FC<Props> = ({
alertOptions,
setAlertFlyoutVisible,
}) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const kibana = useKibana();
const hasUptimeWrite = kibana.services.application?.capabilities.uptime?.save ?? false;
const monitorStatusAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'xpack.uptime.toggleAlertFlyout',
@ -108,6 +119,8 @@ export const ToggleAlertFlyoutButtonComponent: React.FC<Props> = ({
name: ToggleFlyoutTranslations.openAlertContextPanelLabel,
icon: 'bell',
panel: ALERT_CONTEXT_SELECT_TYPE_PANEL_ID,
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite,
},
managementContextItem,
],

View file

@ -15,6 +15,7 @@ import {
Nullish,
} from '@testing-library/react';
import { Router } from 'react-router-dom';
import { merge } from 'lodash';
import { createMemoryHistory, History } from 'history';
import { CoreStart } from 'kibana/public';
import { I18nProvider } from '@kbn/i18n-react';
@ -37,12 +38,16 @@ import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mo
import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks';
import { UptimeRefreshContextProvider, UptimeStartupPluginsContextProvider } from '../../contexts';
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
interface KibanaProps {
services?: KibanaServices;
}
export interface KibanaProviderOptions<ExtraCore> {
core?: Partial<CoreStart> & ExtraCore;
core?: DeepPartial<CoreStart> & Partial<ExtraCore>;
kibanaProps?: KibanaProps;
}
@ -64,7 +69,7 @@ type Url =
interface RenderRouterOptions<ExtraCore> extends KibanaProviderOptions<ExtraCore> {
history?: History;
renderOptions?: Omit<RenderOptions, 'queries'>;
state?: Partial<AppState>;
state?: Partial<AppState> | DeepPartial<AppState>;
url?: Url;
}
@ -137,10 +142,8 @@ export function MockKibanaProvider<ExtraCore>({
core,
kibanaProps,
}: MockKibanaProviderProps<ExtraCore>) {
const coreOptions = {
...mockCore(),
...core,
};
const coreOptions = merge({}, mockCore(), core);
return (
<KibanaContextProvider services={{ ...coreOptions }} {...kibanaProps}>
<UptimeRefreshContextProvider>
@ -185,10 +188,7 @@ export function render<ExtraCore>(
url,
}: RenderRouterOptions<ExtraCore> = {}
) {
const testState: AppState = {
...mockState,
...state,
};
const testState: AppState = merge({}, mockState, state);
if (url) {
history = getHistoryFromUrl(url);
@ -233,3 +233,26 @@ export const forNearestButton =
noOtherButtonHasText && node.textContent === text && node.tagName.toLowerCase() === 'button'
);
});
export const makeUptimePermissionsCore = (
permissions: Partial<{
'alerting:save': boolean;
configureSettings: boolean;
save: boolean;
show: boolean;
}>
) => {
return {
application: {
capabilities: {
uptime: {
'alerting:save': true,
configureSettings: true,
save: true,
show: true,
...permissions,
},
},
},
};
};

View file

@ -89,3 +89,5 @@ export const journeySelector = ({ journeys }: AppState) => journeys;
export const networkEventsSelector = ({ networkEvents }: AppState) => networkEvents;
export const syntheticsSelector = ({ synthetics }: AppState) => synthetics;
export const uptimeWriteSelector = (state: AppState) => state;