diff --git a/examples/no_data/README.md b/examples/no_data/README.md new file mode 100755 index 0000000000000..a4274c85fb7f2 --- /dev/null +++ b/examples/no_data/README.md @@ -0,0 +1,6 @@ +# NoDataExamples + +A Kibana plugin to demonstrate the stateful capabilities of integrated NoDataPage services: + + - getAnalyticsNoDataPageFlavor + - useHasApiKeys diff --git a/examples/no_data/common/index.ts b/examples/no_data/common/index.ts new file mode 100644 index 0000000000000..850e235869387 --- /dev/null +++ b/examples/no_data/common/index.ts @@ -0,0 +1,10 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const PLUGIN_ID = 'noDataExamples'; +export const PLUGIN_NAME = 'NoDataExamples'; diff --git a/examples/no_data/kibana.jsonc b/examples/no_data/kibana.jsonc new file mode 100644 index 0000000000000..b3a6dc4e069e8 --- /dev/null +++ b/examples/no_data/kibana.jsonc @@ -0,0 +1,15 @@ +{ + "type": "plugin", + "id": "@kbn/no-data-page-example-plugin", + "owner": "@elastic/appex-sharedux", + "description": "A Kibana plugin to demonstrate the stateful capabilities of integrated NoDataPage services", + "plugin": { + "id": "no_data_page_example", + "server": false, + "browser": true, + "requiredPlugins": [ + "developerExamples", + "noDataPage" + ] + } +} diff --git a/examples/no_data/package.json b/examples/no_data/package.json new file mode 100644 index 0000000000000..01a83d65c6bce --- /dev/null +++ b/examples/no_data/package.json @@ -0,0 +1,12 @@ +{ + "name": "noDataExamples", + "version": "0.0.0", + "private": true, + "scripts": { + "bootstrap": "yarn kbn bootstrap && yarn install", + "build": "yarn plugin-helpers build", + "dev": "yarn plugin-helpers dev", + "plugin-helpers": "node ../../scripts/plugin_helpers", + "kbn": "node ../../scripts/kbn" + } +} diff --git a/examples/no_data/public/application.tsx b/examples/no_data/public/application.tsx new file mode 100644 index 0000000000000..c4aedc5c2c93e --- /dev/null +++ b/examples/no_data/public/application.tsx @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, CoreStart } from '@kbn/core/public'; +import { NoDataExamplesApp } from './components/app'; +import { NoDataExamplesPluginSetupDeps } from '.'; + +export const renderApp = ( + _: CoreStart, + { element }: AppMountParameters, + { noDataPage }: Pick +) => { + ReactDOM.render(, element); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/examples/no_data/public/components/app.tsx b/examples/no_data/public/components/app.tsx new file mode 100644 index 0000000000000..c1fd58d236c88 --- /dev/null +++ b/examples/no_data/public/components/app.tsx @@ -0,0 +1,95 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useState } from 'react'; + +import { EuiButton, EuiCode, EuiHorizontalRule, EuiLoadingSpinner, EuiText } from '@elastic/eui'; +import { NoDataPagePluginSetup } from '@kbn/no-data-page-plugin/public'; +import { KibanaErrorBoundary, KibanaErrorBoundaryProvider } from '@kbn/shared-ux-error-boundary'; +import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; +import { PLUGIN_NAME } from '../../common'; + +interface NoDataExamplesAppDeps { + noDataPage: NoDataPagePluginSetup; +} + +export const NoDataExamplesApp: React.FC = ({ noDataPage }) => { + // Use React hooks to manage state. + const [showHasApiKeys, setShowHasApiKeys] = useState(null); + + const onClickHandler = () => { + setShowHasApiKeys(true); + }; + + const ShowHasApiKeys = () => { + const hasApiKeysResponse = noDataPage.useHasApiKeys(); + if (hasApiKeysResponse == null) { + return <>undetermined; + } + const { hasApiKeys, loading, error } = hasApiKeysResponse; + + if (error) { + throw error; + } + + if (loading) { + return ; + } + + return <>{hasApiKeys ? 'yes' : 'no'}; + }; + + // Render the application DOM. + // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. + return ( + + + + + + +

+ Service: hasApiKeys +
+ + Current user has API keys:{' '} + {showHasApiKeys ? : 'unknown'} + +

+

Click to determine whether the user has created active API keys.

+ + Click + +
+
+ + + +

+ Service: getAnalyticsNoDataPageFlavor +
+ Analytics NoDataPage Flavor:{' '} + + {noDataPage.getAnalyticsNoDataPageFlavor() ?? 'undefined'} + +

+
+
+
+
+
+ ); +}; diff --git a/examples/no_data/public/index.ts b/examples/no_data/public/index.ts new file mode 100644 index 0000000000000..4d08f6d2474cd --- /dev/null +++ b/examples/no_data/public/index.ts @@ -0,0 +1,23 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public'; +import { NoDataPagePluginSetup } from '@kbn/no-data-page-plugin/public'; +import { NoDataExamplesPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new NoDataExamplesPlugin(); +} +export type { NoDataExamplesPluginSetup, NoDataExamplesPluginStart } from './types'; + +export interface NoDataExamplesPluginSetupDeps { + developerExamples: DeveloperExamplesSetup; + noDataPage: NoDataPagePluginSetup; +} diff --git a/examples/no_data/public/plugin.ts b/examples/no_data/public/plugin.ts new file mode 100644 index 0000000000000..1148865ad46fb --- /dev/null +++ b/examples/no_data/public/plugin.ts @@ -0,0 +1,49 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; +import { NoDataExamplesPluginSetup, NoDataExamplesPluginStart } from './types'; +import { PLUGIN_ID, PLUGIN_NAME } from '../common'; +import { NoDataExamplesPluginSetupDeps } from '.'; + +export class NoDataExamplesPlugin + implements Plugin +{ + public setup(core: CoreSetup, deps: NoDataExamplesPluginSetupDeps) { + const { developerExamples, noDataPage } = deps; + + // Register an application into the side navigation menu + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services as specified in kibana.json + const [coreStart] = await core.getStartServices(); + // Render the application + return renderApp(coreStart, params, { noDataPage }); + }, + }); + + // This section is only needed to get this example plugin to show up in our Developer Examples. + developerExamples.register({ + appId: PLUGIN_ID, + title: PLUGIN_NAME, + description: `Demonstrates the stateful capabilities of integrated NoDataPage services`, + }); + + return {}; + } + + public start(_: CoreStart): NoDataExamplesPluginStart { + return {}; + } + + public stop() {} +} diff --git a/examples/no_data/public/types.ts b/examples/no_data/public/types.ts new file mode 100644 index 0000000000000..f95799723b48a --- /dev/null +++ b/examples/no_data/public/types.ts @@ -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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface NoDataExamplesPluginSetup {} + +export type NoDataExamplesPluginStart = NoDataExamplesPluginSetup; diff --git a/examples/no_data/translations/ja-JP.json b/examples/no_data/translations/ja-JP.json new file mode 100644 index 0000000000000..9b0c46d526673 --- /dev/null +++ b/examples/no_data/translations/ja-JP.json @@ -0,0 +1,81 @@ +{ + "formats": { + "number": { + "currency": { + "style": "currency" + }, + "percent": { + "style": "percent" + } + }, + "date": { + "short": { + "month": "numeric", + "day": "numeric", + "year": "2-digit" + }, + "medium": { + "month": "short", + "day": "numeric", + "year": "numeric" + }, + "long": { + "month": "long", + "day": "numeric", + "year": "numeric" + }, + "full": { + "weekday": "long", + "month": "long", + "day": "numeric", + "year": "numeric" + } + }, + "time": { + "short": { + "hour": "numeric", + "minute": "numeric" + }, + "medium": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric" + }, + "long": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + }, + "full": { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short" + } + }, + "relative": { + "years": { + "units": "year" + }, + "months": { + "units": "month" + }, + "days": { + "units": "day" + }, + "hours": { + "units": "hour" + }, + "minutes": { + "units": "minute" + }, + "seconds": { + "units": "second" + } + } + }, + "messages": { + "noDataExamples.buttonText": "Translate me to Japanese" + } +} diff --git a/examples/no_data/tsconfig.json b/examples/no_data/tsconfig.json new file mode 100644 index 0000000000000..41e1609c2bf78 --- /dev/null +++ b/examples/no_data/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target/types" + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../typings/**/*" + ], + "exclude": [] +} diff --git a/package.json b/package.json index 524e1e02cf6a2..8ccbeb8bef1b0 100644 --- a/package.json +++ b/package.json @@ -574,6 +574,7 @@ "@kbn/navigation-plugin": "link:src/plugins/navigation", "@kbn/newsfeed-plugin": "link:src/plugins/newsfeed", "@kbn/newsfeed-test-plugin": "link:test/common/plugins/newsfeed", + "@kbn/no-data-page-example-plugin": "link:examples/no_data", "@kbn/no-data-page-plugin": "link:src/plugins/no_data_page", "@kbn/notifications-plugin": "link:x-pack/plugins/notifications", "@kbn/object-versioning": "link:packages/kbn-object-versioning", diff --git a/src/plugins/no_data_page/common/index.ts b/src/plugins/no_data_page/common/index.ts new file mode 100644 index 0000000000000..9b5d0e3fcd432 --- /dev/null +++ b/src/plugins/no_data_page/common/index.ts @@ -0,0 +1,13 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const NO_DATA_API_PATHS: Record> = { + internal: { + hasApiKeys: '/internal/no_data/has_api_keys', + }, +}; diff --git a/src/plugins/no_data_page/common/types.ts b/src/plugins/no_data_page/common/types.ts new file mode 100644 index 0000000000000..aba6db9768721 --- /dev/null +++ b/src/plugins/no_data_page/common/types.ts @@ -0,0 +1,11 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export interface HasApiKeysApiResponse { + has_api_keys: boolean; +} diff --git a/src/plugins/no_data_page/kibana.jsonc b/src/plugins/no_data_page/kibana.jsonc index 202917173b7a4..808205ec89607 100644 --- a/src/plugins/no_data_page/kibana.jsonc +++ b/src/plugins/no_data_page/kibana.jsonc @@ -5,6 +5,9 @@ "plugin": { "id": "noDataPage", "server": true, - "browser": true + "browser": true, + "optionalPlugins": [ + "security" + ] } } diff --git a/src/plugins/no_data_page/public/lib/use_has_api_keys.ts b/src/plugins/no_data_page/public/lib/use_has_api_keys.ts new file mode 100644 index 0000000000000..2fb13ad7a300c --- /dev/null +++ b/src/plugins/no_data_page/public/lib/use_has_api_keys.ts @@ -0,0 +1,37 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { HttpSetup } from '@kbn/core-http-browser'; +import { useEffect, useState } from 'react'; +import { NO_DATA_API_PATHS } from '../../common'; +import type { HasApiKeysApiResponse } from '../../common/types'; +import { HasApiKeysResponse } from '../types'; + +export const createUseHasApiKeys = ({ http }: { http: HttpSetup }) => { + return function useHasApiKeys(): HasApiKeysResponse { + const [hasApiKeys, setHasApiKeys] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + http + .get(NO_DATA_API_PATHS.internal.hasApiKeys) + .then((response) => { + setHasApiKeys(response.has_api_keys); + setLoading(false); + }) + .catch((caughtError) => { + setError(caughtError); + setLoading(false); + }); + }, []); + + return { hasApiKeys, loading, error }; + }; +}; diff --git a/src/plugins/no_data_page/public/mocks/index.ts b/src/plugins/no_data_page/public/mocks/index.ts new file mode 100644 index 0000000000000..7989ec54cbd87 --- /dev/null +++ b/src/plugins/no_data_page/public/mocks/index.ts @@ -0,0 +1,22 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { NoDataPagePluginSetup, NoDataPagePluginStart } from '../types'; + +const initialize = () => { + return () => + ({ + getAnalyticsNoDataPageFlavor: () => 'kibana', + useHasApiKeys: () => null, + } as T); +}; + +export const noDataPagePluginMock = { + createSetup: initialize(), + createStart: initialize(), +}; diff --git a/src/plugins/no_data_page/public/plugin.ts b/src/plugins/no_data_page/public/plugin.ts index 740f796f4f395..caa0259deb96a 100644 --- a/src/plugins/no_data_page/public/plugin.ts +++ b/src/plugins/no_data_page/public/plugin.ts @@ -6,26 +6,33 @@ * Side Public License, v 1. */ -import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; -import type { NoDataPagePluginSetup, NoDataPagePluginStart } from './types'; +import type { + CoreSetup, + CoreStart, + HttpSetup, + Plugin, + PluginInitializerContext, +} from '@kbn/core/public'; import type { NoDataPageConfig } from '../config'; +import type { NoDataPagePluginSetup, NoDataPagePluginStart } from './types'; +import { createUseHasApiKeys } from './lib/use_has_api_keys'; export class NoDataPagePlugin implements Plugin { constructor(private initializerContext: PluginInitializerContext) {} public setup(core: CoreSetup): NoDataPagePluginSetup { - return { - getAnalyticsNoDataPageFlavor: () => { - return this.initializerContext.config.get().analyticsNoDataPageFlavor; - }, - }; + return this.initialize(core); } public start(core: CoreStart): NoDataPagePluginStart { + return this.initialize(core); + } + + private initialize({ http }: { http: HttpSetup }): NoDataPagePluginSetup { return { - getAnalyticsNoDataPageFlavor: () => { - return this.initializerContext.config.get().analyticsNoDataPageFlavor; - }, + getAnalyticsNoDataPageFlavor: () => + this.initializerContext.config.get().analyticsNoDataPageFlavor, + useHasApiKeys: createUseHasApiKeys({ http }), }; } } diff --git a/src/plugins/no_data_page/public/types.ts b/src/plugins/no_data_page/public/types.ts index 2e33170ec06bf..bae191bbbfbfc 100644 --- a/src/plugins/no_data_page/public/types.ts +++ b/src/plugins/no_data_page/public/types.ts @@ -6,8 +6,27 @@ * Side Public License, v 1. */ +export type GetAnalyticsNoDataPageFlavor = () => + | 'kibana' + | 'serverless_search' + | 'serverless_observability'; + +export interface HasApiKeysResponseData { + hasApiKeys: boolean; +} + +export interface HasApiKeysResponse { + hasApiKeys: boolean | null; + loading: boolean; + error: Error | null; +} + export interface NoDataPagePluginSetup { - getAnalyticsNoDataPageFlavor: () => 'kibana' | 'serverless_search' | 'serverless_observability'; + getAnalyticsNoDataPageFlavor: GetAnalyticsNoDataPageFlavor; + /** + * The response can be stubbed with null as a default, if the No Data Page is unavailable + */ + useHasApiKeys: () => HasApiKeysResponse | null; } export type NoDataPagePluginStart = NoDataPagePluginSetup; diff --git a/src/plugins/no_data_page/server/index.ts b/src/plugins/no_data_page/server/index.ts index ba02a016a9676..adc0025855f25 100644 --- a/src/plugins/no_data_page/server/index.ts +++ b/src/plugins/no_data_page/server/index.ts @@ -6,9 +6,13 @@ * Side Public License, v 1. */ -import { PluginConfigDescriptor } from '@kbn/core-plugins-server'; - -import { configSchema, NoDataPageConfig } from '../config'; +import type { + PluginConfigDescriptor, + PluginInitializer, + PluginInitializerContext, +} from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin-types-server'; +import { configSchema, type NoDataPageConfig } from '../config'; export const config: PluginConfigDescriptor = { exposeToBrowser: { @@ -17,9 +21,13 @@ export const config: PluginConfigDescriptor = { schema: configSchema, }; -export function plugin() { - return new (class NoDataPagePlugin { - setup() {} - start() {} - })(); +export const plugin: PluginInitializer<{}, {}, {}, NoDataPagePluginStartDeps> = async ( + initializerContext: PluginInitializerContext +) => { + const { NoDataPagePlugin } = await import('./plugin'); + return new NoDataPagePlugin(initializerContext); +}; + +export interface NoDataPagePluginStartDeps { + security?: SecurityPluginStart; } diff --git a/src/plugins/no_data_page/server/plugin.ts b/src/plugins/no_data_page/server/plugin.ts new file mode 100644 index 0000000000000..baf9d9658b42f --- /dev/null +++ b/src/plugins/no_data_page/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + CoreSetup, + CoreStart, + Logger, + Plugin, + PluginInitializerContext, +} from '@kbn/core/server'; +import { NoDataPagePluginStartDeps } from '.'; +import { getHasApiKeysRoute } from './routes'; + +export class NoDataPagePlugin implements Plugin { + private readonly logger: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.logger = this.initializerContext.logger.get(); + } + + setup(core: CoreSetup) { + core.getStartServices().then(([_, startDeps]) => { + const { security } = startDeps; + + // initialize internal route(s) + getHasApiKeysRoute(core.http.createRouter(), { + logger: this.logger, + security, + }); + }); + + return {}; + } + + start(_: CoreStart) { + return {}; + } +} diff --git a/src/plugins/no_data_page/server/routes/index.ts b/src/plugins/no_data_page/server/routes/index.ts new file mode 100644 index 0000000000000..8317a85e5aa3f --- /dev/null +++ b/src/plugins/no_data_page/server/routes/index.ts @@ -0,0 +1,65 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + IRouter, + Logger, + KibanaRequest, + KibanaResponseFactory, + RequestHandlerContext, +} from '@kbn/core/server'; +import type { SecurityPluginStart } from '@kbn/security-plugin-types-server'; +import { NO_DATA_API_PATHS } from '../../common'; +import type { HasApiKeysApiResponse } from '../../common/types'; + +interface GetHasApiKeysRoutePluginDeps { + logger: Logger; + security?: SecurityPluginStart; +} + +/** + * Register a internal route that informs the UI whether the currently logged-in user has created API keys in + * Elasticsearch Security. This is used to direct users effectively through their getting started experience. + * + * NOTE: The user may not have the necessary privilege to call the required Elasticsearch API. + * NOTE: The user may have created API keys that have been invalidated, which explicitly do not get counted the result. + * NOTE: The Elasticsearch cluster may not have API keys enabled. + */ +export const getHasApiKeysRoute = (router: IRouter, deps: GetHasApiKeysRoutePluginDeps) => { + const { logger, security } = deps; + + router.get( + { + path: NO_DATA_API_PATHS.internal.hasApiKeys, + validate: {}, + }, + async function handler( + _: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ) { + let hasApiKeys: boolean | null = null; + + try { + const result = await security?.authc.apiKeys.hasApiKeys(req, { + ownerOnly: true, + validOnly: true, + }); + hasApiKeys = result ?? null; + } catch (e) { + logger.error(e); + } + + return res.ok({ + body: { + has_api_keys: hasApiKeys ?? false, + }, + }); + } + ); +}; diff --git a/test/examples/config.js b/test/examples/config.js index dbc9d32055cc7..8310d3240d618 100644 --- a/test/examples/config.js +++ b/test/examples/config.js @@ -32,6 +32,7 @@ export default async function ({ readConfigFile }) { require.resolve('./unified_field_list_examples'), require.resolve('./discover_customization_examples'), require.resolve('./error_boundary'), + require.resolve('./no_data'), ], services: { ...functionalConfig.get('services'), diff --git a/test/examples/no_data/index.ts b/test/examples/no_data/index.ts new file mode 100644 index 0000000000000..578b2ce26f327 --- /dev/null +++ b/test/examples/no_data/index.ts @@ -0,0 +1,66 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { PLUGIN_ID as NO_DATA_PLUGIN_ID } from '@kbn/no-data-page-example-plugin/common'; +import { FtrProviderContext } from '../../functional/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['common']); + const log = getService('log'); + const es = getService('es'); + + describe('No Data Page Examples', () => { + before(async () => { + await PageObjects.common.navigateToApp(NO_DATA_PLUGIN_ID); + await testSubjects.existOrFail('noDataPageExampleHeader'); + }); + + it('determine when user has no API keys', async () => { + await retry.try(async () => { + const sectionText = await testSubjects.getVisibleText('noDataPageExampleHasApiKeysResult'); + expect(sectionText).to.be('Current user has API keys: unknown'); + }); + + log.debug('clicking button for checking API keys'); + await testSubjects.click('noDataPageExampleHasApiKeysClick'); + + await retry.try(async () => { + const sectionText = await testSubjects.getVisibleText('noDataPageExampleHasApiKeysResult'); + expect(sectionText).to.be('Current user has API keys: no'); + }); + }); + + // can not be tested with example plugins tests server + it.skip('determine when user has API key(s)', async () => { + const { id: keyId } = await es.security.createApiKey({ + name: 'key-for-test', + }); + + await retry.try(async () => { + const sectionText = await testSubjects.getVisibleText('noDataPageExampleHasApiKeysResult'); + expect(sectionText).to.be('Current user has API keys: unknown'); + }); + + log.debug('clicking button for checking API keys'); + await testSubjects.click('noDataPageExampleHasApiKeysClick'); + + await retry.try(async () => { + const sectionText = await testSubjects.getVisibleText('noDataPageExampleHasApiKeysResult'); + expect(sectionText).to.be('Current user has API keys: yes'); + }); + + await es.security.invalidateApiKey({ + id: keyId, + }); + }); + }); +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e634fe61248a1..2a21f4fd5293c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1106,6 +1106,8 @@ "@kbn/newsfeed-plugin/*": ["src/plugins/newsfeed/*"], "@kbn/newsfeed-test-plugin": ["test/common/plugins/newsfeed"], "@kbn/newsfeed-test-plugin/*": ["test/common/plugins/newsfeed/*"], + "@kbn/no-data-page-example-plugin": ["examples/no_data"], + "@kbn/no-data-page-example-plugin/*": ["examples/no_data/*"], "@kbn/no-data-page-plugin": ["src/plugins/no_data_page"], "@kbn/no-data-page-plugin/*": ["src/plugins/no_data_page/*"], "@kbn/notifications-plugin": ["x-pack/plugins/notifications"], diff --git a/x-pack/packages/security/plugin_types_server/index.ts b/x-pack/packages/security/plugin_types_server/index.ts index 2d697dd0187ab..ad1a298d08986 100644 --- a/x-pack/packages/security/plugin_types_server/index.ts +++ b/x-pack/packages/security/plugin_types_server/index.ts @@ -18,6 +18,7 @@ export type { CreateAPIKeyResult, CreateRestAPIKeyParams, GrantAPIKeyResult, + HasApiKeysOptions, InvalidateAPIKeysParams, ValidateAPIKeyParams, CreateRestAPIKeyWithKibanaPrivilegesParams, diff --git a/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/api_keys.ts b/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/api_keys.ts index 1cbf13a4ad45f..09efa8297788b 100644 --- a/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/api_keys.ts +++ b/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/api_keys.ts @@ -17,6 +17,12 @@ export interface APIKeys { */ areAPIKeysEnabled(): Promise; + /** + * Determines if the currently logged in user has created API keys + * @param apiKeyPrams ValidateAPIKeyParams. + */ + hasApiKeys(request: KibanaRequest, options: HasApiKeysOptions): Promise; + /** * Determines if Cross-Cluster API Keys are enabled in Elasticsearch. */ @@ -116,6 +122,14 @@ export interface ValidateAPIKeyParams { api_key: string; } +/** + * + */ +export interface HasApiKeysOptions { + ownerOnly: boolean; + validOnly: boolean; +} + /** * Represents the params for invalidating multiple API keys */ diff --git a/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/index.ts b/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/index.ts index dbad1344d1d24..02af946dd1fbb 100644 --- a/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/index.ts +++ b/x-pack/packages/security/plugin_types_server/src/authentication/api_keys/index.ts @@ -8,6 +8,7 @@ export type { CreateAPIKeyParams, CreateAPIKeyResult, + HasApiKeysOptions, InvalidateAPIKeyResult, InvalidateAPIKeysParams, ValidateAPIKeyParams, diff --git a/x-pack/packages/security/plugin_types_server/src/authentication/index.ts b/x-pack/packages/security/plugin_types_server/src/authentication/index.ts index 04e4a820fb4d9..4a9f7b888e92a 100644 --- a/x-pack/packages/security/plugin_types_server/src/authentication/index.ts +++ b/x-pack/packages/security/plugin_types_server/src/authentication/index.ts @@ -11,6 +11,7 @@ export type { CreateRestAPIKeyParams, CreateRestAPIKeyWithKibanaPrivilegesParams, CreateCrossClusterAPIKeyParams, + HasApiKeysOptions, InvalidateAPIKeyResult, InvalidateAPIKeysParams, ValidateAPIKeyParams, diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 36a3bfeee4f7c..f56a447d76b46 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -16,6 +16,7 @@ import type { CreateRestAPIKeyParams, CreateRestAPIKeyWithKibanaPrivilegesParams, GrantAPIKeyResult, + HasApiKeysOptions, InvalidateAPIKeyResult, InvalidateAPIKeysParams, ValidateAPIKeyParams, @@ -83,6 +84,32 @@ export class APIKeys implements APIKeysType { this.kibanaFeatures = kibanaFeatures; } + /** + * Determines if currently-logged-in user has created any API Keys. + * NOTE: The current user may not have privileges to call the requred Elasticsearch API. + */ + async hasApiKeys(request: KibanaRequest, options: HasApiKeysOptions): Promise { + const { ownerOnly, validOnly } = options; + + try { + const scopedClusterClient = this.clusterClient.asScoped(request); + const result = await scopedClusterClient.asCurrentUser.security.getApiKey({ + owner: ownerOnly, + }); + const { api_keys: apiKeys } = result; + + let countedKeys = apiKeys; + if (validOnly) { + countedKeys = countedKeys.filter((key) => !key.invalidated); + } + + return countedKeys.length > 0; + } catch (e) { + this.logger.error(`Failed to determine if user has created any API keys`); + return false; + } + } + /** * Determines if API Keys are enabled in Elasticsearch. */ diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index d6f955b8b4558..9ff741480214e 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -69,6 +69,7 @@ export interface InternalAuthenticationServiceStart extends AuthenticationServic | 'areAPIKeysEnabled' | 'areCrossClusterAPIKeysEnabled' | 'create' + | 'hasApiKeys' | 'update' | 'invalidate' | 'validate' @@ -366,6 +367,7 @@ export class AuthenticationService { create: apiKeys.create.bind(apiKeys), update: apiKeys.update.bind(apiKeys), grantAsInternalUser: apiKeys.grantAsInternalUser.bind(apiKeys), + hasApiKeys: apiKeys.hasApiKeys.bind(apiKeys), invalidate: apiKeys.invalidate.bind(apiKeys), validate: apiKeys.validate.bind(apiKeys), invalidateAsInternalUser: apiKeys.invalidateAsInternalUser.bind(apiKeys), diff --git a/yarn.lock b/yarn.lock index 1db13cef13340..f61fa4cea502f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5147,6 +5147,10 @@ version "0.0.0" uid "" +"@kbn/no-data-page-example-plugin@link:examples/no_data": + version "0.0.0" + uid "" + "@kbn/no-data-page-plugin@link:src/plugins/no_data_page": version "0.0.0" uid ""