[License Management] Add URL locator (#153792)

## Summary

Fixes https://github.com/elastic/kibana/issues/153104
Fixes https://github.com/elastic/kibana/issues/153037

This PR adds a URL locator to the License Management plugin that can be
used by other plugins to safely render links to that plugin. An example
of that is implemented in the Watcher plugin.

### How to test
Since there is some logic in the code that disables Watcher if the
license is not valid for that feature, it is actually not very probable
to run into an issue with an invalid license in the UI. But we still can
mock the license status and test the changes of this PR:
1. Start ES with the trial license `yarn es snapshot --license=trial`
and start Kibana `yarn start`.
2. Update this
[file](https://github.com/elastic/kibana/blob/main/x-pack/plugins/watcher/public/application/app.tsx)
to mock an invalid license status: change line 61 to `if (true) {`.
3. Navigate to Watcher to see that there is a link to License
Management.
4. Update this
[file](https://github.com/elastic/kibana/blob/main/x-pack/plugins/watcher/public/application/app.tsx)
to mock an undefined License Management URL locator (imitates that the
License Management plugin is disabled): change line 63 to
`<LicensePrompt message={message} />`.
5. Navigate to Watcher to see that there is not link to License
Management.

#### Screenshots
When the license status is invalid and the License Management plugin is
enabled (not changed)
<img width="1259" alt="Screenshot 2023-03-28 at 14 55 54"
src="https://user-images.githubusercontent.com/6585477/228833667-a96337cc-52c4-40fc-9209-37f7c0adab33.png">

When the license status is invalid and the License Management plugin is
disabled
<img width="1258" alt="Screenshot 2023-03-28 at 14 55 27"
src="https://user-images.githubusercontent.com/6585477/228834043-7aa270ba-92c4-48cd-9c75-3a060e3924eb.png">


### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yulia Čech 2023-03-31 14:44:03 +02:00 committed by GitHub
parent 546ceabc15
commit a77ece24f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 293 additions and 32 deletions

View file

@ -14,7 +14,8 @@
"home",
"licensing",
"management",
"features"
"features",
"share"
],
"optionalPlugins": [
"telemetry"

View file

@ -17,6 +17,7 @@ import {
EuiPageBody,
EuiEmptyPrompt,
} from '@elastic/eui';
import { UPLOAD_LICENSE_ROUTE } from '../locator';
export const App = ({
hasPermission,
@ -102,7 +103,7 @@ export const App = ({
return (
<EuiPageBody>
<Switch>
<Route path={`/upload_license`} component={withTelemetry(UploadLicense)} />
<Route path={`/${UPLOAD_LICENSE_ROUTE}`} component={withTelemetry(UploadLicense)} />
<Route path={['/']} component={withTelemetry(LicenseDashboard)} />
</Switch>
</EuiPageBody>

View file

@ -0,0 +1,34 @@
/*
* 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 { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { ManagementAppLocatorDefinition } from '@kbn/management-plugin/common/locator';
import { LicenseManagementLocatorDefinition, LICENSE_MANAGEMENT_LOCATOR_ID } from './locator';
describe('License Management URL locator', () => {
let locator: LicenseManagementLocatorDefinition;
beforeEach(() => {
const managementDefinition = new ManagementAppLocatorDefinition();
locator = new LicenseManagementLocatorDefinition({
managementAppLocator: {
...sharePluginMock.createLocator(),
getLocation: (params) => managementDefinition.getLocation(params),
},
});
});
test('locator has the right ID', () => {
expect(locator.id).toBe(LICENSE_MANAGEMENT_LOCATOR_ID);
});
test('locator returns the correct url for dashboard page', async () => {
const { path } = await locator.getLocation({ page: 'dashboard' });
expect(path).toBe('/stack/license_management');
});
test('locator returns the correct url for upload license page', async () => {
const { path } = await locator.getLocation({ page: 'upload_license' });
expect(path).toBe('/stack/license_management/upload_license');
});
});

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SerializableRecord } from '@kbn/utility-types';
import { ManagementAppLocator } from '@kbn/management-plugin/common';
import { LocatorDefinition, LocatorPublic } from '@kbn/share-plugin/public';
import { PLUGIN } from '../common/constants';
export const LICENSE_MANAGEMENT_LOCATOR_ID = 'LICENSE_MANAGEMENT_LOCATOR';
export const UPLOAD_LICENSE_ROUTE = 'upload_license';
export interface LicenseManagementLocatorParams extends SerializableRecord {
page: 'dashboard' | 'upload_license';
}
export type LicenseManagementLocator = LocatorPublic<LicenseManagementLocatorParams>;
export interface LicenseManagementLocatorDefinitionDependencies {
managementAppLocator: ManagementAppLocator;
}
export class LicenseManagementLocatorDefinition
implements LocatorDefinition<LicenseManagementLocatorParams>
{
constructor(protected readonly deps: LicenseManagementLocatorDefinitionDependencies) {}
public readonly id = LICENSE_MANAGEMENT_LOCATOR_ID;
public readonly getLocation = async (params: LicenseManagementLocatorParams) => {
const location = await this.deps.managementAppLocator.getLocation({
sectionId: 'stack',
appId: PLUGIN.id,
});
switch (params.page) {
case 'upload_license': {
return {
...location,
path: `${location.path}/${UPLOAD_LICENSE_ROUTE}`,
};
}
case 'dashboard': {
return location;
}
}
};
}

View file

@ -11,14 +11,17 @@ import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public';
import { TelemetryPluginStart } from '@kbn/telemetry-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
import { LicensingPluginSetup } from '@kbn/licensing-plugin/public';
import { SharePluginSetup } from '@kbn/share-plugin/public';
import { PLUGIN } from '../common/constants';
import { ClientConfigType } from './types';
import { AppDependencies } from './application';
import { BreadcrumbService } from './application/breadcrumbs';
import { LicenseManagementLocator, LicenseManagementLocatorDefinition } from './locator';
interface PluginsDependenciesSetup {
management: ManagementSetup;
licensing: LicensingPluginSetup;
share: SharePluginSetup;
}
interface PluginsDependenciesStart {
@ -27,6 +30,7 @@ interface PluginsDependenciesStart {
export interface LicenseManagementUIPluginSetup {
enabled: boolean;
locator: undefined | LicenseManagementLocator;
}
export type LicenseManagementUIPluginStart = void;
@ -34,6 +38,7 @@ export class LicenseManagementUIPlugin
implements Plugin<LicenseManagementUIPluginSetup, LicenseManagementUIPluginStart, any, any>
{
private breadcrumbService = new BreadcrumbService();
private locator?: LicenseManagementLocator;
constructor(private readonly initializerContext: PluginInitializerContext) {}
@ -47,11 +52,18 @@ export class LicenseManagementUIPlugin
// No need to go any further
return {
enabled: false,
locator: this.locator,
};
}
const { getStartServices } = coreSetup;
const { management, licensing } = plugins;
const { management, licensing, share } = plugins;
this.locator = share.url.locators.create(
new LicenseManagementLocatorDefinition({
managementAppLocator: management.locator,
})
);
management.sections.section.stack.registerApp({
id: PLUGIN.id,
@ -105,6 +117,7 @@ export class LicenseManagementUIPlugin
return {
enabled: true,
locator: this.locator,
};
}

View file

@ -25,6 +25,8 @@
"@kbn/config-schema",
"@kbn/test-jest-helpers",
"@kbn/shared-ux-router",
"@kbn/utility-types",
"@kbn/share-plugin",
],
"exclude": [
"target/**/*",

View file

@ -0,0 +1,80 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`License prompt renders a prompt with a link to License Management 1`] = `
<EuiPageContent_Deprecated
color="danger"
horizontalPosition="center"
verticalPosition="center"
>
<EuiEmptyPrompt
actions={
Array [
<EuiLink
href="/license_management"
>
<FormattedMessage
defaultMessage="Manage your license"
id="xpack.watcher.app.licenseErrorLinkText"
values={Object {}}
/>
</EuiLink>,
]
}
body={
<p>
License error
</p>
}
iconType="warning"
title={
<h1>
<FormattedMessage
defaultMessage="License error"
id="xpack.watcher.app.licenseErrorTitle"
values={Object {}}
/>
</h1>
}
/>
</EuiPageContent_Deprecated>
`;
exports[`License prompt renders a prompt without a link to License Management 1`] = `
<EuiPageContent_Deprecated
color="danger"
horizontalPosition="center"
verticalPosition="center"
>
<EuiEmptyPrompt
actions={
Array [
undefined,
]
}
body={
<React.Fragment>
<p>
License error
</p>
<p>
<FormattedMessage
defaultMessage="Contact your administrator to change your license."
id="xpack.watcher.app.licenseErrorBody"
values={Object {}}
/>
</p>
</React.Fragment>
}
iconType="warning"
title={
<h1>
<FormattedMessage
defaultMessage="License error"
id="xpack.watcher.app.licenseErrorTitle"
values={Object {}}
/>
</h1>
}
/>
</EuiPageContent_Deprecated>
`;

View file

@ -0,0 +1,35 @@
/*
* 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 { shallow } from 'enzyme';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import {
LicenseManagementLocator,
LicenseManagementLocatorParams,
} from '@kbn/license-management-plugin/public/locator';
import { LicensePrompt } from '../public/application/license_prompt';
describe('License prompt', () => {
test('renders a prompt with a link to License Management', () => {
const locator = {
...sharePluginMock.createLocator(),
useUrl: (params: LicenseManagementLocatorParams) => '/license_management',
} as LicenseManagementLocator;
const component = shallow(
<LicensePrompt message="License error" licenseManagementLocator={locator} />
);
expect(component).toMatchSnapshot();
});
test('renders a prompt without a link to License Management', () => {
const component = shallow(<LicensePrompt message="License error" />);
expect(component).toMatchSnapshot();
});
});

View file

@ -19,6 +19,9 @@
"data",
"features"
],
"optionalPlugins": [
"licenseManagement"
],
"requiredBundles": [
"esUiShared",
"kibanaReact",

View file

@ -20,17 +20,15 @@ import { Router, Switch, Redirect, withRouter, RouteComponentProps } from 'react
import { Route } from '@kbn/shared-ux-router';
import { EuiPageContent_Deprecated as EuiPageContent, EuiEmptyPrompt, EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { RegisterManagementAppArgs, ManagementAppMountParams } from '@kbn/management-plugin/public';
import { ChartsPluginSetup } from '@kbn/charts-plugin/public';
import { LicenseManagementLocator } from '@kbn/license-management-plugin/public/locator';
import { LicenseStatus } from '../../common/types/license_status';
import { WatchListPage, WatchEditPage, WatchStatusPage } from './sections';
import { registerRouter } from './lib/navigation';
import { AppContextProvider } from './app_context';
import { LicensePrompt } from './license_prompt';
const ShareRouter = withRouter(({ children, history }: RouteComponentProps & { children: any }) => {
registerRouter({ history });
@ -49,6 +47,7 @@ export interface AppDeps {
history: ManagementAppMountParams['history'];
getUrlForApp: ApplicationStart['getUrlForApp'];
executionContext: ExecutionContextStart;
licenseManagementLocator?: LicenseManagementLocator;
}
export const App = (deps: AppDeps) => {
@ -61,30 +60,7 @@ export const App = (deps: AppDeps) => {
if (!valid) {
return (
<EuiPageContent verticalPosition="center" horizontalPosition="center" color="danger">
<EuiEmptyPrompt
iconType="warning"
title={
<h1>
<FormattedMessage
id="xpack.watcher.app.licenseErrorTitle"
defaultMessage="License error"
/>
</h1>
}
body={<p>{message}</p>}
actions={[
<EuiLink
href={deps.getUrlForApp('management', { path: 'stack/license_management/home' })}
>
<FormattedMessage
id="xpack.watcher.app.licenseErrorLinkText"
defaultMessage="Manage your license"
/>
</EuiLink>,
]}
/>
</EuiPageContent>
<LicensePrompt licenseManagementLocator={deps.licenseManagementLocator} message={message} />
);
}
return (

View file

@ -0,0 +1,60 @@
/*
* 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 { FormattedMessage } from '@kbn/i18n-react';
import { EuiEmptyPrompt, EuiLink, EuiPageContent_Deprecated as EuiPageContent } from '@elastic/eui';
import { LicenseManagementLocator } from '@kbn/license-management-plugin/public/locator';
export const LicensePrompt = ({
message,
licenseManagementLocator,
}: {
message: string | undefined;
licenseManagementLocator?: LicenseManagementLocator;
}) => {
const licenseManagementUrl = licenseManagementLocator?.useUrl({ page: 'dashboard' });
// if there is no licenseManagementUrl, the license management plugin might be disabled
const promptAction = licenseManagementUrl ? (
<EuiLink href={licenseManagementUrl}>
<FormattedMessage
id="xpack.watcher.app.licenseErrorLinkText"
defaultMessage="Manage your license"
/>
</EuiLink>
) : undefined;
const promptBody = licenseManagementUrl ? (
<p>{message}</p>
) : (
<>
<p>{message}</p>
<p>
<FormattedMessage
id="xpack.watcher.app.licenseErrorBody"
defaultMessage="Contact your administrator to change your license."
/>
</p>
</>
);
return (
<EuiPageContent verticalPosition="center" horizontalPosition="center" color="danger">
<EuiEmptyPrompt
iconType="warning"
title={
<h1>
<FormattedMessage
id="xpack.watcher.app.licenseErrorTitle"
defaultMessage="License error"
/>
</h1>
}
body={promptBody}
actions={[promptAction]}
/>
</EuiPageContent>
);
};

View file

@ -29,7 +29,7 @@ export class WatcherUIPlugin implements Plugin<void, void, Dependencies, any> {
setup(
{ notifications, http, uiSettings, getStartServices }: CoreSetup,
{ licensing, management, data, home, charts }: Dependencies
{ licensing, management, data, home, charts, licenseManagement }: Dependencies
) {
const esSection = management.sections.section.insightsAndAlerting;
@ -75,6 +75,7 @@ export class WatcherUIPlugin implements Plugin<void, void, Dependencies, any> {
history,
getUrlForApp: application.getUrlForApp,
theme$,
licenseManagementLocator: licenseManagement?.locator,
executionContext,
});

View file

@ -10,6 +10,7 @@ import { ChartsPluginStart } from '@kbn/charts-plugin/public';
import { LicensingPluginSetup } from '@kbn/licensing-plugin/public';
import { DataPublicPluginSetup } from '@kbn/data-plugin/public';
import { HomePublicPluginSetup } from '@kbn/home-plugin/public';
import { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public';
export interface Dependencies {
home: HomePublicPluginSetup;
@ -17,4 +18,5 @@ export interface Dependencies {
licensing: LicensingPluginSetup;
charts: ChartsPluginStart;
data: DataPublicPluginSetup;
licenseManagement?: LicenseManagementUIPluginSetup;
}

View file

@ -33,6 +33,8 @@
"@kbn/i18n-react",
"@kbn/ace",
"@kbn/shared-ux-router",
"@kbn/license-management-plugin",
"@kbn/share-plugin",
],
"exclude": [
"target/**/*",