diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js
index 328c769d81c66..0af75e72de78a 100644
--- a/.buildkite/scripts/steps/storybooks/build_and_upload.js
+++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js
@@ -15,6 +15,7 @@ const STORYBOOKS = [
'apm',
'canvas',
'ci_composite',
+ 'cloud',
'codeeditor',
'custom_integrations',
'dashboard_enhanced',
diff --git a/api_docs/cloud.devdocs.json b/api_docs/cloud.devdocs.json
index 6c6dbf6f48003..ce0ded5fa875b 100644
--- a/api_docs/cloud.devdocs.json
+++ b/api_docs/cloud.devdocs.json
@@ -2,7 +2,24 @@
"id": "cloud",
"client": {
"classes": [],
- "functions": [],
+ "functions": [
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.Chat",
+ "type": "Function",
+ "tags": [],
+ "label": "Chat",
+ "description": [],
+ "signature": [
+ "() => JSX.Element"
+ ],
+ "path": "x-pack/plugins/cloud/public/components/index.tsx",
+ "deprecated": false,
+ "children": [],
+ "returnComment": [],
+ "initialIsOpen": false
+ }
+ ],
"interfaces": [
{
"parentPluginId": "cloud",
@@ -11,7 +28,7 @@
"tags": [],
"label": "CloudConfigType",
"description": [],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"children": [
{
@@ -24,7 +41,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -37,7 +54,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -50,7 +67,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -63,7 +80,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -76,7 +93,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -89,7 +106,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -102,8 +119,78 @@
"signature": [
"{ enabled: boolean; org_id?: string | undefined; }"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
+ },
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.CloudConfigType.chat",
+ "type": "Object",
+ "tags": [],
+ "label": "chat",
+ "description": [],
+ "signature": [
+ "{ enabled: boolean; chatURL: string; }"
+ ],
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
+ "deprecated": false
+ }
+ ],
+ "initialIsOpen": false
+ },
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.CloudStart",
+ "type": "Interface",
+ "tags": [],
+ "label": "CloudStart",
+ "description": [],
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
+ "deprecated": false,
+ "children": [
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.CloudStart.CloudContextProvider",
+ "type": "Function",
+ "tags": [],
+ "label": "CloudContextProvider",
+ "description": [
+ "\nA React component that provides a pre-wired `React.Context` which connects components to Cloud services."
+ ],
+ "signature": [
+ "React.FunctionComponent<{}>"
+ ],
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
+ "deprecated": false,
+ "returnComment": [],
+ "children": [
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.CloudStart.CloudContextProvider.$1",
+ "type": "CompoundType",
+ "tags": [],
+ "label": "props",
+ "description": [],
+ "signature": [
+ "P & { children?: React.ReactNode; }"
+ ],
+ "path": "node_modules/@types/react/index.d.ts",
+ "deprecated": false
+ },
+ {
+ "parentPluginId": "cloud",
+ "id": "def-public.CloudStart.CloudContextProvider.$2",
+ "type": "Any",
+ "tags": [],
+ "label": "context",
+ "description": [],
+ "signature": [
+ "any"
+ ],
+ "path": "node_modules/@types/react/index.d.ts",
+ "deprecated": false
+ }
+ ]
}
],
"initialIsOpen": false
@@ -119,7 +206,7 @@
"tags": [],
"label": "CloudSetup",
"description": [],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"children": [
{
@@ -132,7 +219,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -145,7 +232,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -158,7 +245,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -171,7 +258,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -184,7 +271,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -197,7 +284,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -210,7 +297,7 @@
"signature": [
"string | undefined"
],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@@ -220,7 +307,7 @@
"tags": [],
"label": "isCloudEnabled",
"description": [],
- "path": "x-pack/plugins/cloud/public/plugin.ts",
+ "path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
}
],
diff --git a/package.json b/package.json
index 501204174fb15..a9b4023adeb00 100644
--- a/package.json
+++ b/package.json
@@ -278,6 +278,7 @@
"json-stable-stringify": "^1.0.1",
"json-stringify-pretty-compact": "1.2.0",
"json-stringify-safe": "5.0.1",
+ "jsonwebtoken": "^8.3.0",
"jsts": "^1.6.2",
"kea": "^2.4.2",
"load-json-file": "^6.2.0",
diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts
index 896ed6b0bb536..d6526781e7373 100644
--- a/src/dev/storybook/aliases.ts
+++ b/src/dev/storybook/aliases.ts
@@ -11,6 +11,7 @@ export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
ci_composite: '.ci/.storybook',
+ cloud: 'x-pack/plugins/cloud/.storybook',
codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook',
controls: 'src/plugins/controls/storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts
index abeb14de4c43b..ed6497573a362 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.test.ts
@@ -8,7 +8,7 @@
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
}));
-// eslint-disable-next-line import/no-extraneous-dependencies
+
import jwt from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts
index 9f5102b336eda..79230a99a3822 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/lib/create_jwt_assertion.ts
@@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-// eslint-disable-next-line import/no-extraneous-dependencies
+
import jwt, { Algorithm } from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';
diff --git a/x-pack/plugins/cloud/.storybook/decorator.tsx b/x-pack/plugins/cloud/.storybook/decorator.tsx
new file mode 100644
index 0000000000000..4489b58f75759
--- /dev/null
+++ b/x-pack/plugins/cloud/.storybook/decorator.tsx
@@ -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 { DecoratorFn } from '@storybook/react';
+import { ServicesProvider, CloudServices } from '../public/services';
+
+// TODO: move to a storybook implementation of the service using parameters.
+const services: CloudServices = {
+ chat: {
+ enabled: true,
+ chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
+ user: {
+ id: 'user-id',
+ email: 'test-user@elastic.co',
+ // this doesn't affect chat appearance,
+ // but a user identity in Drift only
+ jwt: 'identity-jwt',
+ },
+ },
+};
+
+export const getCloudContextProvider: () => React.FC =
+ () =>
+ ({ children }) =>
+ {children};
+
+export const getCloudContextDecorator: DecoratorFn = (storyFn) => {
+ const CloudContextProvider = getCloudContextProvider();
+ return {storyFn()};
+};
diff --git a/x-pack/plugins/cloud/.storybook/index.ts b/x-pack/plugins/cloud/.storybook/index.ts
new file mode 100644
index 0000000000000..321df983cb20d
--- /dev/null
+++ b/x-pack/plugins/cloud/.storybook/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { getCloudContextDecorator, getCloudContextProvider } from './decorator';
diff --git a/x-pack/plugins/cloud/.storybook/main.ts b/x-pack/plugins/cloud/.storybook/main.ts
new file mode 100644
index 0000000000000..bf63e08d64c32
--- /dev/null
+++ b/x-pack/plugins/cloud/.storybook/main.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { defaultConfig } from '@kbn/storybook';
+
+module.exports = defaultConfig;
diff --git a/x-pack/plugins/cloud/.storybook/manager.ts b/x-pack/plugins/cloud/.storybook/manager.ts
new file mode 100644
index 0000000000000..54c3d31c2002f
--- /dev/null
+++ b/x-pack/plugins/cloud/.storybook/manager.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { addons } from '@storybook/addons';
+import { create } from '@storybook/theming';
+import { PANEL_ID } from '@storybook/addon-actions';
+
+addons.setConfig({
+ theme: create({
+ base: 'light',
+ brandTitle: 'Cloud Storybook',
+ brandUrl: 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud',
+ }),
+ showPanel: true.valueOf,
+ selectedPanel: PANEL_ID,
+});
diff --git a/x-pack/plugins/cloud/.storybook/preview.ts b/x-pack/plugins/cloud/.storybook/preview.ts
new file mode 100644
index 0000000000000..83c512e516d5a
--- /dev/null
+++ b/x-pack/plugins/cloud/.storybook/preview.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { addDecorator } from '@storybook/react';
+import { getCloudContextDecorator } from './decorator';
+
+addDecorator(getCloudContextDecorator);
diff --git a/x-pack/plugins/cloud/common/constants.ts b/x-pack/plugins/cloud/common/constants.ts
index fc37906299d14..09333e3773fe9 100644
--- a/x-pack/plugins/cloud/common/constants.ts
+++ b/x-pack/plugins/cloud/common/constants.ts
@@ -6,6 +6,7 @@
*/
export const ELASTIC_SUPPORT_LINK = 'https://cloud.elastic.co/support';
+export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
/**
* This is the page for managing your snapshots on Cloud.
diff --git a/x-pack/plugins/cloud/common/types.ts b/x-pack/plugins/cloud/common/types.ts
new file mode 100644
index 0000000000000..38ebeaf5f467c
--- /dev/null
+++ b/x-pack/plugins/cloud/common/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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export interface GetChatUserDataResponseBody {
+ token: string;
+ email: string;
+ id: string;
+}
diff --git a/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx b/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx
new file mode 100644
index 0000000000000..668017e134e75
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/chat/chat.stories.tsx
@@ -0,0 +1,20 @@
+/*
+ * 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 { Chat } from './chat';
+
+export default {
+ title: 'Chat Widget',
+ description: '',
+ parameters: {},
+};
+
+export const Component = () => {
+ return ;
+};
diff --git a/x-pack/plugins/cloud/public/components/chat/chat.tsx b/x-pack/plugins/cloud/public/components/chat/chat.tsx
new file mode 100644
index 0000000000000..99b53f553e75f
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/chat/chat.tsx
@@ -0,0 +1,111 @@
+/*
+ * 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, { useEffect, useRef, useState, CSSProperties } from 'react';
+import { css } from '@emotion/react';
+import { useChat } from '../../services';
+import { getChatContext } from './get_chat_context';
+
+type UseChatType =
+ | { enabled: false }
+ | {
+ enabled: true;
+ src: string;
+ ref: React.MutableRefObject;
+ style: CSSProperties;
+ isReady: boolean;
+ };
+
+const MESSAGE_READY = 'driftIframeReady';
+const MESSAGE_RESIZE = 'driftIframeResize';
+const MESSAGE_SET_CONTEXT = 'driftSetContext';
+
+const useChatConfig = (): UseChatType => {
+ const ref = useRef(null);
+ const chat = useChat();
+ const [style, setStyle] = useState({});
+ const [isReady, setIsReady] = useState(false);
+
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent): void => {
+ const { current: chatIframe } = ref;
+
+ if (
+ !chat.enabled ||
+ !chatIframe?.contentWindow ||
+ event.source !== chatIframe?.contentWindow
+ ) {
+ return;
+ }
+
+ const context = getChatContext();
+ const { data: message } = event;
+ const { user: userConfig } = chat;
+ const { id, email, jwt } = userConfig;
+
+ switch (message.type) {
+ case MESSAGE_READY: {
+ const user = {
+ id,
+ attributes: {
+ email,
+ },
+ jwt,
+ };
+
+ chatIframe.contentWindow.postMessage(
+ {
+ type: MESSAGE_SET_CONTEXT,
+ data: { context, user },
+ },
+ '*'
+ );
+
+ setIsReady(true);
+
+ break;
+ }
+
+ case MESSAGE_RESIZE: {
+ const styles = message.data.styles || ({} as CSSProperties);
+ setStyle({ ...style, ...styles });
+ break;
+ }
+
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ return () => window.removeEventListener('message', handleMessage);
+ }, [chat, style]);
+
+ if (chat.enabled) {
+ return { enabled: true, src: chat.chatURL, ref, style, isReady };
+ }
+
+ return { enabled: false };
+};
+
+export const Chat = () => {
+ const config = useChatConfig();
+
+ if (!config.enabled) {
+ return null;
+ }
+
+ const iframeStyle = css`
+ position: fixed;
+ botton: 30px;
+ right: 30px;
+ visibility: ${config.isReady ? 'visible' : 'hidden'};
+ `;
+
+ return ;
+};
diff --git a/x-pack/plugins/cloud/public/components/chat/get_chat_context.test.ts b/x-pack/plugins/cloud/public/components/chat/get_chat_context.test.ts
new file mode 100644
index 0000000000000..e3e2675d0291c
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/chat/get_chat_context.test.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { getChatContext } from './get_chat_context';
+
+const PROTOCOL = 'http:';
+const PORT = '1234';
+const HASH = '#/discover?_g=()&_a=()';
+const HOST_NAME = 'www.kibana.com';
+const PATH_NAME = '/app/kibana';
+const HOST = `${HOST_NAME}:${PORT}`;
+const ORIGIN = `${PROTOCOL}//${HOST}`;
+const HREF = `${ORIGIN}${PATH_NAME}${HASH}`;
+const USER_AGENT = 'user-agent';
+const LANGUAGE = 'la-ng';
+const TITLE = 'title';
+const REFERRER = 'referrer';
+
+describe('getChatContext', () => {
+ const url = new URL(HREF);
+
+ test('retreive the context', () => {
+ Object.defineProperty(window, 'location', { value: url });
+ Object.defineProperty(window, 'navigator', {
+ value: {
+ language: LANGUAGE,
+ userAgent: USER_AGENT,
+ },
+ });
+ Object.defineProperty(window.document, 'referrer', { value: REFERRER });
+ window.document.title = TITLE;
+
+ const context = getChatContext();
+
+ expect(context).toStrictEqual({
+ window: {
+ location: {
+ hash: HASH,
+ host: HOST,
+ hostname: HOST_NAME,
+ href: HREF,
+ origin: ORIGIN,
+ pathname: PATH_NAME,
+ port: PORT,
+ protocol: PROTOCOL,
+ search: '',
+ },
+ navigator: {
+ language: LANGUAGE,
+ userAgent: USER_AGENT,
+ },
+ innerHeight: 768,
+ innerWidth: 1024,
+ },
+ document: {
+ title: TITLE,
+ referrer: REFERRER,
+ },
+ });
+ });
+});
diff --git a/x-pack/plugins/cloud/public/components/chat/get_chat_context.ts b/x-pack/plugins/cloud/public/components/chat/get_chat_context.ts
new file mode 100644
index 0000000000000..e29a2efa24803
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/chat/get_chat_context.ts
@@ -0,0 +1,36 @@
+/*
+ * 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.
+ */
+
+export const getChatContext = () => {
+ const { location, navigator, innerHeight, innerWidth } = window;
+ const { hash, host, hostname, href, origin, pathname, port, protocol, search } = location;
+ const { language, userAgent } = navigator;
+ const { title, referrer } = document;
+
+ return {
+ window: {
+ location: {
+ hash,
+ host,
+ hostname,
+ href,
+ origin,
+ pathname,
+ port,
+ protocol,
+ search,
+ },
+ navigator: { language, userAgent },
+ innerHeight,
+ innerWidth,
+ },
+ document: {
+ title,
+ referrer,
+ },
+ };
+};
diff --git a/x-pack/plugins/cloud/public/components/chat/index.ts b/x-pack/plugins/cloud/public/components/chat/index.ts
new file mode 100644
index 0000000000000..ceed215208b64
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/chat/index.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+/**
+ * A component that will display a trigger that will allow the user to chat with a human operator,
+ * when the service is enabled; otherwise, it renders nothing.
+ */
+export { Chat } from './chat';
diff --git a/x-pack/plugins/cloud/public/components/index.tsx b/x-pack/plugins/cloud/public/components/index.tsx
new file mode 100644
index 0000000000000..e0aab671de279
--- /dev/null
+++ b/x-pack/plugins/cloud/public/components/index.tsx
@@ -0,0 +1,26 @@
+/*
+ * 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, { Suspense } from 'react';
+import { EuiErrorBoundary } from '@elastic/eui';
+
+/**
+ * A suspense-compatible version of the Chat component.
+ */
+export const LazyChat = React.lazy(() => import('./chat').then(({ Chat }) => ({ default: Chat })));
+
+/**
+ * A lazily-loaded component that will display a trigger that will allow the user to chat with a
+ * human operator when the service is enabled; otherwise, it renders nothing.
+ */
+export const Chat = () => (
+
+ }>
+
+
+
+);
diff --git a/x-pack/plugins/cloud/public/index.ts b/x-pack/plugins/cloud/public/index.ts
index d51def6fa6641..73aa3a350e8ea 100644
--- a/x-pack/plugins/cloud/public/index.ts
+++ b/x-pack/plugins/cloud/public/index.ts
@@ -8,7 +8,10 @@
import { PluginInitializerContext } from '../../../../src/core/public';
import { CloudPlugin } from './plugin';
-export type { CloudSetup, CloudConfigType } from './plugin';
+export type { CloudSetup, CloudConfigType, CloudStart } from './plugin';
+
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudPlugin(initializerContext);
}
+
+export { Chat } from './components';
diff --git a/x-pack/plugins/cloud/public/mocks.ts b/x-pack/plugins/cloud/public/mocks.ts
deleted file mode 100644
index 52a027e899d0d..0000000000000
--- a/x-pack/plugins/cloud/public/mocks.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * 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.
- */
-
-function createSetupMock() {
- return {
- cloudId: 'mock-cloud-id',
- isCloudEnabled: true,
- cname: 'cname',
- baseUrl: 'base-url',
- deploymentUrl: 'deployment-url',
- profileUrl: 'profile-url',
- organizationUrl: 'organization-url',
- };
-}
-
-export const cloudMock = {
- createSetup: createSetupMock,
-};
diff --git a/x-pack/plugins/cloud/public/mocks.tsx b/x-pack/plugins/cloud/public/mocks.tsx
new file mode 100644
index 0000000000000..5bef215ffb0ea
--- /dev/null
+++ b/x-pack/plugins/cloud/public/mocks.tsx
@@ -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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+
+import { CloudStart } from '.';
+import { ServicesProvider } from '../public/services';
+
+function createSetupMock() {
+ return {
+ cloudId: 'mock-cloud-id',
+ isCloudEnabled: true,
+ cname: 'cname',
+ baseUrl: 'base-url',
+ deploymentUrl: 'deployment-url',
+ profileUrl: 'profile-url',
+ organizationUrl: 'organization-url',
+ };
+}
+
+const config = {
+ chat: {
+ enabled: true,
+ chatURL: 'chat-url',
+ user: {
+ id: 'user-id',
+ email: 'test-user@elastic.co',
+ jwt: 'identity-jwt',
+ },
+ },
+};
+
+const getContextProvider: () => React.FC =
+ () =>
+ ({ children }) =>
+ {children};
+
+const createStartMock = (): jest.Mocked => ({
+ CloudContextProvider: jest.fn(getContextProvider()),
+});
+
+export const cloudMock = {
+ createSetup: createSetupMock,
+ createStart: createStartMock,
+};
diff --git a/x-pack/plugins/cloud/public/plugin.test.ts b/x-pack/plugins/cloud/public/plugin.test.ts
index 070202782411d..7198fb6f8a774 100644
--- a/x-pack/plugins/cloud/public/plugin.test.ts
+++ b/x-pack/plugins/cloud/public/plugin.test.ts
@@ -40,6 +40,9 @@ describe('Cloud Plugin', () => {
full_story: {
enabled: false,
},
+ chat: {
+ enabled: false,
+ },
...config,
});
@@ -47,11 +50,15 @@ describe('Cloud Plugin', () => {
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
+
if (currentAppId$) {
coreStart.application.currentAppId$ = currentAppId$;
}
+
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
+
const securitySetup = securityMock.createSetup();
+
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
@@ -212,6 +219,101 @@ describe('Cloud Plugin', () => {
});
});
+ describe('setupChat', () => {
+ let consoleMock: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleMock.mockRestore();
+ });
+
+ const setupPlugin = async ({
+ config = {},
+ securityEnabled = true,
+ currentUserProps = {},
+ isCloudEnabled = true,
+ failHttp = false,
+ }: {
+ config?: Partial;
+ securityEnabled?: boolean;
+ currentUserProps?: Record;
+ isCloudEnabled?: boolean;
+ failHttp?: boolean;
+ }) => {
+ const initContext = coreMock.createPluginInitializerContext({
+ id: isCloudEnabled ? 'cloud-id' : null,
+ base_url: 'https://cloud.elastic.co',
+ deployment_url: '/abc123',
+ profile_url: '/profile/alice',
+ organization_url: '/org/myOrg',
+ full_story: {
+ enabled: false,
+ },
+ chat: {
+ enabled: false,
+ },
+ ...config,
+ });
+
+ const plugin = new CloudPlugin(initContext);
+
+ const coreSetup = coreMock.createSetup();
+ const coreStart = coreMock.createStart();
+
+ if (failHttp) {
+ coreSetup.http.get.mockImplementation(() => {
+ throw new Error('HTTP request failed');
+ });
+ }
+
+ coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
+
+ const securitySetup = securityMock.createSetup();
+ securitySetup.authc.getCurrentUser.mockResolvedValue(
+ securityMock.createMockAuthenticatedUser(currentUserProps)
+ );
+
+ const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
+
+ return { initContext, plugin, setup, coreSetup };
+ };
+
+ it('chatConfig is not retrieved if cloud is not enabled', async () => {
+ const { coreSetup } = await setupPlugin({ isCloudEnabled: false });
+ expect(coreSetup.http.get).not.toHaveBeenCalled();
+ });
+
+ it('chatConfig is not retrieved if security is not enabled', async () => {
+ const { coreSetup } = await setupPlugin({ securityEnabled: false });
+ expect(coreSetup.http.get).not.toHaveBeenCalled();
+ });
+
+ it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => {
+ // @ts-expect-error 2741
+ const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } });
+ expect(coreSetup.http.get).not.toHaveBeenCalled();
+ });
+
+ it('chatConfig is not retrieved if internal API fails', async () => {
+ const { coreSetup } = await setupPlugin({
+ config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
+ failHttp: true,
+ });
+ expect(coreSetup.http.get).toHaveBeenCalled();
+ expect(consoleMock).toHaveBeenCalled();
+ });
+
+ it('chatConfig is retrieved if chat is enabled and url is provided', async () => {
+ const { coreSetup } = await setupPlugin({
+ config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
+ });
+ expect(coreSetup.http.get).toHaveBeenCalled();
+ });
+ });
+
describe('interface', () => {
const setupPlugin = () => {
const initContext = coreMock.createPluginInitializerContext({
@@ -221,6 +323,12 @@ describe('Cloud Plugin', () => {
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
+ chat: {
+ enabled: false,
+ },
+ full_story: {
+ enabled: false,
+ },
});
const plugin = new CloudPlugin(initContext);
@@ -284,6 +392,9 @@ describe('Cloud Plugin', () => {
full_story: {
enabled: false,
},
+ chat: {
+ enabled: false,
+ },
})
);
const coreSetup = coreMock.createSetup();
diff --git a/x-pack/plugins/cloud/public/plugin.ts b/x-pack/plugins/cloud/public/plugin.tsx
similarity index 80%
rename from x-pack/plugins/cloud/public/plugin.ts
rename to x-pack/plugins/cloud/public/plugin.tsx
index 81aad8bf79ccc..991a7c1f8b565 100644
--- a/x-pack/plugins/cloud/public/plugin.ts
+++ b/x-pack/plugins/cloud/public/plugin.tsx
@@ -5,6 +5,7 @@
* 2.0.
*/
+import React, { FC } from 'react';
import {
CoreSetup,
CoreStart,
@@ -15,17 +16,24 @@ import {
ApplicationStart,
} from 'src/core/public';
import { i18n } from '@kbn/i18n';
-import { Subscription } from 'rxjs';
+import useObservable from 'react-use/lib/useObservable';
+import { BehaviorSubject, Subscription } from 'rxjs';
import type {
AuthenticatedUser,
SecurityPluginSetup,
SecurityPluginStart,
} from '../../security/public';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
-import { ELASTIC_SUPPORT_LINK, CLOUD_SNAPSHOTS_PATH } from '../common/constants';
+import {
+ ELASTIC_SUPPORT_LINK,
+ CLOUD_SNAPSHOTS_PATH,
+ GET_CHAT_USER_DATA_ROUTE_PATH,
+} from '../common/constants';
+import type { GetChatUserDataResponseBody } from '../common/types';
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import { createUserMenuLinks } from './user_menu_links';
import { getFullCloudUrl } from './utils';
+import { ChatConfig, ServicesProvider } from './services';
export interface CloudConfigType {
id?: string;
@@ -38,6 +46,13 @@ export interface CloudConfigType {
enabled: boolean;
org_id?: string;
};
+ /** Configuration to enable live chat in Cloud-enabled instances of Kibana. */
+ chat: {
+ /** Determines if chat is enabled. */
+ enabled: boolean;
+ /** The URL to the remotely-hosted chat application. */
+ chatURL: string;
+ };
}
interface CloudSetupDependencies {
@@ -49,6 +64,13 @@ interface CloudStartDependencies {
security?: SecurityPluginStart;
}
+export interface CloudStart {
+ /**
+ * A React component that provides a pre-wired `React.Context` which connects components to Cloud services.
+ */
+ CloudContextProvider: FC<{}>;
+}
+
export interface CloudSetup {
cloudId?: string;
cname?: string;
@@ -65,10 +87,15 @@ interface SetupFullstoryDeps extends CloudSetupDependencies {
basePath: IBasePath;
}
+interface SetupChatDeps extends Pick {
+ http: CoreSetup['http'];
+}
+
export class CloudPlugin implements Plugin {
private config!: CloudConfigType;
private isCloudEnabled: boolean;
private appSubscription?: Subscription;
+ private chatConfig$ = new BehaviorSubject({ enabled: false });
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get();
@@ -79,6 +106,7 @@ export class CloudPlugin implements Plugin {
const application = core.getStartServices().then(([coreStart]) => {
return coreStart.application;
});
+
this.setupFullstory({ basePath: core.http.basePath, security, application }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up FullStory: ${e.toString()}`)
@@ -95,6 +123,11 @@ export class CloudPlugin implements Plugin {
this.isCloudEnabled = getIsCloudEnabled(id);
+ this.setupChat({ http: core.http, security }).catch((e) =>
+ // eslint-disable-next-line no-console
+ console.debug(`Error setting up Chat: ${e.toString()}`)
+ );
+
if (home) {
home.environment.update({ cloud: this.isCloudEnabled });
if (this.isCloudEnabled) {
@@ -119,7 +152,7 @@ export class CloudPlugin implements Plugin {
};
}
- public start(coreStart: CoreStart, { security }: CloudStartDependencies) {
+ public start(coreStart: CoreStart, { security }: CloudStartDependencies): CloudStart {
const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config;
coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK);
@@ -147,6 +180,17 @@ export class CloudPlugin implements Plugin {
// In the event of an unexpected error, fail *open*.
// Cloud admin console will always perform the actual authorization checks.
.catch(() => setLinks(true));
+
+ // There's a risk that the request for chat config will take too much time to complete, and the provider
+ // will maintain a stale value. To avoid this, we'll use an Observable.
+ const CloudContextProvider: FC = ({ children }) => {
+ const chatConfig = useObservable(this.chatConfig$, { enabled: false });
+ return {children};
+ };
+
+ return {
+ CloudContextProvider,
+ };
}
public stop() {
@@ -266,6 +310,43 @@ export class CloudPlugin implements Plugin {
...memoryInfo,
});
}
+
+ private async setupChat({ http, security }: SetupChatDeps) {
+ if (!this.isCloudEnabled) {
+ return;
+ }
+
+ const { enabled, chatURL } = this.config.chat;
+
+ if (!security || !enabled || !chatURL) {
+ return;
+ }
+
+ try {
+ const {
+ email,
+ id,
+ token: jwt,
+ } = await http.get(GET_CHAT_USER_DATA_ROUTE_PATH);
+
+ if (!email || !id || !jwt) {
+ return;
+ }
+
+ this.chatConfig$.next({
+ enabled,
+ chatURL,
+ user: {
+ email,
+ id,
+ jwt,
+ },
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.debug(`[cloud.chat] Could not retrieve chat config: ${e.res.status} ${e.message}`, e);
+ }
+ }
}
/** @internal exported for testing */
diff --git a/x-pack/plugins/cloud/public/services/index.tsx b/x-pack/plugins/cloud/public/services/index.tsx
new file mode 100644
index 0000000000000..96b80ae308883
--- /dev/null
+++ b/x-pack/plugins/cloud/public/services/index.tsx
@@ -0,0 +1,46 @@
+/*
+ * 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, { FC, createContext, useContext } from 'react';
+
+interface WithoutChat {
+ enabled: false;
+}
+
+interface WithChat {
+ enabled: true;
+ chatURL: string;
+ user: {
+ jwt: string;
+ id: string;
+ email: string;
+ };
+}
+
+export type ChatConfig = WithChat | WithoutChat;
+
+export interface CloudServices {
+ chat: ChatConfig;
+}
+
+const ServicesContext = createContext({ chat: { enabled: false } });
+
+export const ServicesProvider: FC = ({ children, ...services }) => (
+ {children}
+);
+
+/**
+ * React hook for accessing the pre-wired `CloudServices`.
+ */
+export function useServices() {
+ return useContext(ServicesContext);
+}
+
+export function useChat(): ChatConfig {
+ const { chat } = useServices();
+ return chat;
+}
diff --git a/x-pack/plugins/cloud/server/config.ts b/x-pack/plugins/cloud/server/config.ts
index 109987cd72d44..c5a122c2ec702 100644
--- a/x-pack/plugins/cloud/server/config.ts
+++ b/x-pack/plugins/cloud/server/config.ts
@@ -28,28 +28,36 @@ const fullStoryConfigSchema = schema.object({
),
});
+const chatConfigSchema = schema.object({
+ enabled: schema.boolean({ defaultValue: false }),
+ chatURL: schema.maybe(schema.string()),
+});
+
const configSchema = schema.object({
- id: schema.maybe(schema.string()),
apm: schema.maybe(apmConfigSchema),
- cname: schema.maybe(schema.string()),
base_url: schema.maybe(schema.string()),
- profile_url: schema.maybe(schema.string()),
+ chat: chatConfigSchema,
+ chatIdentitySecret: schema.maybe(schema.string()),
+ cname: schema.maybe(schema.string()),
deployment_url: schema.maybe(schema.string()),
- organization_url: schema.maybe(schema.string()),
full_story: fullStoryConfigSchema,
+ id: schema.maybe(schema.string()),
+ organization_url: schema.maybe(schema.string()),
+ profile_url: schema.maybe(schema.string()),
});
export type CloudConfigType = TypeOf;
export const config: PluginConfigDescriptor = {
exposeToBrowser: {
- id: true,
- cname: true,
base_url: true,
- profile_url: true,
+ chat: true,
+ cname: true,
deployment_url: true,
- organization_url: true,
full_story: true,
+ id: true,
+ organization_url: true,
+ profile_url: true,
},
schema: configSchema,
};
diff --git a/x-pack/plugins/cloud/server/plugin.ts b/x-pack/plugins/cloud/server/plugin.ts
index 4c0b7b7f7eca6..a5cf423fb8295 100644
--- a/x-pack/plugins/cloud/server/plugin.ts
+++ b/x-pack/plugins/cloud/server/plugin.ts
@@ -7,14 +7,17 @@
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/server';
+import type { SecurityPluginSetup } from '../../security/server';
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { parseDeploymentIdFromDeploymentUrl } from './utils';
import { registerFullstoryRoute } from './routes/fullstory';
+import { registerChatRoute } from './routes/chat';
interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
+ security?: SecurityPluginSetup;
}
export interface CloudSetup {
@@ -30,13 +33,15 @@ export interface CloudSetup {
export class CloudPlugin implements Plugin {
private readonly logger: Logger;
private readonly config: CloudConfigType;
+ private isDev: boolean;
constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
this.config = this.context.config.get();
+ this.isDev = this.context.env.mode.dev;
}
- public setup(core: CoreSetup, { usageCollection }: PluginsSetup) {
+ public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup) {
this.logger.debug('Setting up Cloud plugin');
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
@@ -48,6 +53,15 @@ export class CloudPlugin implements Plugin {
});
}
+ if (this.config.chat.enabled && this.config.chatIdentitySecret) {
+ registerChatRoute({
+ router: core.http.createRouter(),
+ chatIdentitySecret: this.config.chatIdentitySecret,
+ security,
+ isDev: this.isDev,
+ });
+ }
+
return {
cloudId: this.config.id,
deploymentId: parseDeploymentIdFromDeploymentUrl(this.config.deployment_url),
diff --git a/x-pack/plugins/cloud/server/routes/chat.test.ts b/x-pack/plugins/cloud/server/routes/chat.test.ts
new file mode 100644
index 0000000000000..9ed76eff6d081
--- /dev/null
+++ b/x-pack/plugins/cloud/server/routes/chat.test.ts
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+jest.mock('jsonwebtoken', () => ({
+ sign: () => {
+ return 'json-web-token';
+ },
+}));
+
+import { httpServiceMock, httpServerMock } from '../../../../../src/core/server/mocks';
+import { securityMock } from '../../../security/server/mocks';
+import { kibanaResponseFactory } from 'src/core/server';
+import { registerChatRoute } from './chat';
+
+describe('chat route', () => {
+ test('do not add the route if security is not enabled', async () => {
+ const router = httpServiceMock.createRouter();
+ registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret' });
+ expect(router.get.mock.calls).toEqual([]);
+ });
+
+ test('error if no user', async () => {
+ const security = securityMock.createSetup();
+ security.authc.getCurrentUser.mockReturnValueOnce(null);
+
+ const router = httpServiceMock.createRouter();
+ registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' });
+
+ const [_config, handler] = router.get.mock.calls[0];
+
+ await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
+ .toMatchInlineSnapshot(`
+ KibanaResponse {
+ "options": Object {
+ "body": "User has no email or username",
+ },
+ "payload": "User has no email or username",
+ "status": 400,
+ }
+ `);
+ });
+
+ test('returns user information and a token', async () => {
+ const security = securityMock.createSetup();
+ const username = 'user.name';
+ const email = 'user@elastic.co';
+
+ security.authc.getCurrentUser.mockReturnValueOnce({
+ username,
+ email,
+ });
+
+ const router = httpServiceMock.createRouter();
+ registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' });
+ const [_config, handler] = router.get.mock.calls[0];
+ await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
+ .toMatchInlineSnapshot(`
+ KibanaResponse {
+ "options": Object {
+ "body": Object {
+ "email": "${email}",
+ "id": "${username}",
+ "token": "json-web-token",
+ },
+ },
+ "payload": Object {
+ "email": "${email}",
+ "id": "${username}",
+ "token": "json-web-token",
+ },
+ "status": 200,
+ }
+ `);
+ });
+
+ test('returns placeholder user information and a token in dev mode', async () => {
+ const security = securityMock.createSetup();
+ const username = 'first.last';
+ const email = 'test+first.last@elasticsearch.com';
+
+ security.authc.getCurrentUser.mockReturnValueOnce({});
+
+ const router = httpServiceMock.createRouter();
+ registerChatRoute({ router, security, isDev: true, chatIdentitySecret: 'secret' });
+ const [_config, handler] = router.get.mock.calls[0];
+ await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
+ .toMatchInlineSnapshot(`
+ KibanaResponse {
+ "options": Object {
+ "body": Object {
+ "email": "${email}",
+ "id": "${username}",
+ "token": "json-web-token",
+ },
+ },
+ "payload": Object {
+ "email": "${email}",
+ "id": "${username}",
+ "token": "json-web-token",
+ },
+ "status": 200,
+ }
+ `);
+ });
+});
diff --git a/x-pack/plugins/cloud/server/routes/chat.ts b/x-pack/plugins/cloud/server/routes/chat.ts
new file mode 100644
index 0000000000000..62c4475c92ae5
--- /dev/null
+++ b/x-pack/plugins/cloud/server/routes/chat.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { IRouter } from '../../../../../src/core/server';
+import type { SecurityPluginSetup } from '../../../security/server';
+import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants';
+import type { GetChatUserDataResponseBody } from '../../common/types';
+import { generateSignedJwt } from '../util/generate_jwt';
+
+export const registerChatRoute = ({
+ router,
+ chatIdentitySecret,
+ security,
+ isDev,
+}: {
+ router: IRouter;
+ chatIdentitySecret: string;
+ security?: SecurityPluginSetup;
+ isDev: boolean;
+}) => {
+ if (!security) {
+ return;
+ }
+
+ router.get(
+ {
+ path: GET_CHAT_USER_DATA_ROUTE_PATH,
+ validate: {},
+ },
+ async (_context, request, response) => {
+ const user = security.authc.getCurrentUser(request);
+ let { email: userEmail, username: userId } = user || {};
+
+ // In local development, these values are not populated. This is a workaround
+ // to allow for local testing.
+ if (isDev) {
+ if (!userId) {
+ userId = 'first.last';
+ }
+ if (!userEmail) {
+ userEmail = userEmail || `test+${userId}@elasticsearch.com`;
+ }
+ }
+
+ if (!userEmail || !userId) {
+ return response.badRequest({
+ body: 'User has no email or username',
+ });
+ }
+
+ const token = generateSignedJwt(userId, chatIdentitySecret);
+ const body: GetChatUserDataResponseBody = {
+ token,
+ email: userEmail,
+ id: userId,
+ };
+ return response.ok({ body });
+ }
+ );
+};
diff --git a/x-pack/plugins/cloud/server/util/generate_jwt.test.ts b/x-pack/plugins/cloud/server/util/generate_jwt.test.ts
new file mode 100644
index 0000000000000..65fecc05c84ca
--- /dev/null
+++ b/x-pack/plugins/cloud/server/util/generate_jwt.test.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; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+jest.mock('jsonwebtoken', () => ({
+ sign: (payload: {}, secret: string, options: {}) => {
+ return `${JSON.stringify(payload)}.${secret}.${JSON.stringify(options)}`;
+ },
+}));
+
+import { generateSignedJwt } from './generate_jwt';
+
+describe('generateSignedJwt', () => {
+ test('creating a JWT token', () => {
+ const jwtToken = generateSignedJwt('test', '123456');
+ expect(jwtToken).toEqual(
+ '{"sub":"test"}.123456.{"header":{"alg":"HS256","typ":"JWT"},"expiresIn":300}'
+ );
+ });
+});
diff --git a/x-pack/plugins/cloud/server/util/generate_jwt.ts b/x-pack/plugins/cloud/server/util/generate_jwt.ts
new file mode 100644
index 0000000000000..1001a89beb7db
--- /dev/null
+++ b/x-pack/plugins/cloud/server/util/generate_jwt.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 jwt from 'jsonwebtoken';
+
+export const generateSignedJwt = (userId: string, secret: string): string => {
+ const options = {
+ header: {
+ alg: 'HS256',
+ typ: 'JWT',
+ },
+ expiresIn: 5 * 60, // 5m
+ };
+
+ const payload = {
+ sub: userId,
+ };
+
+ return jwt.sign(payload, secret, options);
+};
diff --git a/x-pack/plugins/cloud/tsconfig.json b/x-pack/plugins/cloud/tsconfig.json
index d1ff8c63e84cb..e743b46ac17eb 100644
--- a/x-pack/plugins/cloud/tsconfig.json
+++ b/x-pack/plugins/cloud/tsconfig.json
@@ -7,9 +7,11 @@
"declarationMap": true,
},
"include": [
+ ".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
+ "../../../typings/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
diff --git a/x-pack/plugins/fleet/.storybook/context/index.tsx b/x-pack/plugins/fleet/.storybook/context/index.tsx
index 68348a4d8d07a..eb19a1145ba75 100644
--- a/x-pack/plugins/fleet/.storybook/context/index.tsx
+++ b/x-pack/plugins/fleet/.storybook/context/index.tsx
@@ -53,7 +53,10 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
...stubbedStartServices,
application: getApplication(),
chrome: getChrome(),
- cloud: getCloud({ isCloudEnabled }),
+ cloud: {
+ ...getCloud({ isCloudEnabled }),
+ CloudContextProvider: () => <>>,
+ },
customIntegrations: {
ContextProvider: getStorybookContextProvider(),
},
diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json
index 01b78e107a467..ec03442ce860f 100644
--- a/x-pack/plugins/fleet/kibana.json
+++ b/x-pack/plugins/fleet/kibana.json
@@ -11,5 +11,5 @@
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security"],
"optionalPlugins": ["features", "cloud", "usageCollection", "home", "globalSearch", "telemetry"],
"extraPublicDirs": ["common"],
- "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils", "usageCollection"]
+ "requiredBundles": ["kibanaReact", "cloud", "esUiShared", "home", "infra", "kibanaUtils", "usageCollection"]
}
diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx
index e4724ca13b411..2c5b3b98c3e9a 100644
--- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx
+++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx
@@ -21,6 +21,7 @@ import {
RedirectAppLinks,
} from '../../../../../../src/plugins/kibana_react/public';
import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common';
+import { Chat } from '../../../../cloud/public';
import { KibanaThemeProvider } from '../../../../../../src/plugins/kibana_react/public';
@@ -33,6 +34,8 @@ import { EPMApp } from './sections/epm';
import { PackageInstallProvider, UIExtensionsContext } from './hooks';
import { IntegrationsHeader } from './components/header';
+const EmptyContext = () => <>>;
+
/**
* Fleet Application context all the way down to the Router, but with no permissions or setup checks
* and no routes defined
@@ -60,6 +63,7 @@ export const IntegrationsAppContext: React.FC<{
theme$,
}) => {
const isDarkMode = useObservable(startServices.uiSettings.get$('theme:darkMode'));
+ const CloudContext = startServices.cloud?.CloudContextProvider || EmptyContext;
return (
@@ -73,17 +77,20 @@ export const IntegrationsAppContext: React.FC<{
-
-
-
-
- {children}
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
diff --git a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx
index f810b0369c161..4d8f74fa3b04a 100644
--- a/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx
+++ b/x-pack/plugins/fleet/public/mock/fleet_start_services.tsx
@@ -16,7 +16,7 @@ import { setHttpClient } from '../hooks/use_request';
import type { FleetAuthz } from '../../common';
-import { createStartDepsMock } from './plugin_dependencies';
+import { createStartDepsMock, createSetupDepsMock } from './plugin_dependencies';
import type { MockedFleetStartServices } from './types';
// Taken from core. See: src/plugins/kibana_utils/public/storage/storage.test.ts
@@ -71,9 +71,16 @@ const configureStartServices = (services: MockedFleetStartServices): void => {
};
export const createStartServices = (basePath: string = '/mock'): MockedFleetStartServices => {
+ const { cloud: cloudStart, ...startDeps } = createStartDepsMock();
+ const { cloud: cloudSetup } = createSetupDepsMock();
+
const startServices: MockedFleetStartServices = {
...coreMock.createStart({ basePath }),
- ...createStartDepsMock(),
+ ...startDeps,
+ cloud: {
+ ...cloudStart,
+ ...cloudSetup,
+ },
storage: new Storage(createMockStore()) as jest.Mocked,
authz: fleetAuthzMock,
};
diff --git a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts
index 0bf0213905e72..bd119c147aec7 100644
--- a/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts
+++ b/x-pack/plugins/fleet/public/mock/plugin_dependencies.ts
@@ -7,27 +7,29 @@
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
+import { cloudMock } from '../../../cloud/public/mocks';
import { homePluginMock } from '../../../../../src/plugins/home/public/mocks';
import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks';
import { customIntegrationsMock } from '../../../../../src/plugins/custom_integrations/public/mocks';
import { sharePluginMock } from '../../../../../src/plugins/share/public/mocks';
-import type { MockedFleetSetupDeps, MockedFleetStartDeps } from './types';
-
-export const createSetupDepsMock = (): MockedFleetSetupDeps => {
+export const createSetupDepsMock = () => {
+ const cloud = cloudMock.createSetup();
return {
licensing: licensingMock.createSetup(),
data: dataPluginMock.createSetupContract(),
home: homePluginMock.createSetupContract(),
customIntegrations: customIntegrationsMock.createSetup(),
+ cloud,
};
};
-export const createStartDepsMock = (): MockedFleetStartDeps => {
+export const createStartDepsMock = () => {
return {
data: dataPluginMock.createStartContract(),
navigation: navigationPluginMock.createStartContract(),
customIntegrations: customIntegrationsMock.createStart(),
share: sharePluginMock.createStartContract(),
+ cloud: cloudMock.createStart(),
};
};
diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts
index a15be583d7a1e..548319e7bfaba 100644
--- a/x-pack/plugins/fleet/public/plugin.ts
+++ b/x-pack/plugins/fleet/public/plugin.ts
@@ -26,6 +26,8 @@ import type { SharePluginStart } from 'src/plugins/share/public';
import { once } from 'lodash';
+import type { CloudStart } from '../../cloud/public';
+
import type { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public';
@@ -94,12 +96,13 @@ export interface FleetStartDeps {
navigation: NavigationPublicPluginStart;
customIntegrations: CustomIntegrationsStart;
share: SharePluginStart;
+ cloud?: CloudStart;
}
-export interface FleetStartServices extends CoreStart, FleetStartDeps {
+export interface FleetStartServices extends CoreStart, Exclude {
storage: Storage;
share: SharePluginStart;
- cloud?: CloudSetup;
+ cloud?: CloudSetup & CloudStart;
authz: FleetAuthz;
}
@@ -141,11 +144,16 @@ export class FleetPlugin implements Plugin {
const [coreStartServices, startDepsServices, fleetStart] = await core.getStartServices();
+ const cloud =
+ deps.cloud && startDepsServices.cloud
+ ? { ...deps.cloud, ...startDepsServices.cloud }
+ : undefined;
+
const startServices: FleetStartServices = {
...coreStartServices,
...startDepsServices,
storage: this.storage,
- cloud: deps.cloud,
+ cloud,
authz: await fleetStart.authz,
};
const { renderApp, teardownIntegrations } = await import('./applications/integrations');
@@ -178,11 +186,15 @@ export class FleetPlugin implements Plugin {
const [coreStartServices, startDepsServices, fleetStart] = await core.getStartServices();
+ const cloud =
+ deps.cloud && startDepsServices.cloud
+ ? { ...deps.cloud, ...startDepsServices.cloud }
+ : undefined;
const startServices: FleetStartServices = {
...coreStartServices,
...startDepsServices,
storage: this.storage,
- cloud: deps.cloud,
+ cloud,
authz: await fleetStart.authz,
};
const { renderApp, teardownFleet } = await import('./applications/fleet');
diff --git a/x-pack/test/cloud_integration/config.ts b/x-pack/test/cloud_integration/config.ts
index a012dfd1ad34b..102d276b34584 100644
--- a/x-pack/test/cloud_integration/config.ts
+++ b/x-pack/test/cloud_integration/config.ts
@@ -14,6 +14,10 @@ const FULLSTORY_ORG_ID = process.env.FULLSTORY_ORG_ID;
const FULLSTORY_API_KEY = process.env.FULLSTORY_API_KEY;
const RUN_FULLSTORY_TESTS = Boolean(FULLSTORY_ORG_ID && FULLSTORY_API_KEY);
+const CHAT_URL = process.env.CHAT_URL;
+const CHAT_IDENTITY_SECRET = process.env.CHAT_IDENTITY_SECRET;
+const RUN_CHAT_TESTS = Boolean(CHAT_URL);
+
// the default export of config files must be a config provider
// that returns an object with the projects config values
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
@@ -29,7 +33,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const samlIdPPlugin = resolve(__dirname, './fixtures/saml/saml_provider');
return {
- testFiles: [...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : [])],
+ testFiles: [
+ ...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : []),
+ ...(RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat')] : []),
+ ...(!RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat_disabled')] : []),
+ ],
services,
pageObjects,
@@ -69,6 +77,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
`--xpack.cloud.full_story.org_id=${FULLSTORY_ORG_ID}`,
]
: []),
+ ...(RUN_CHAT_TESTS
+ ? [
+ '--xpack.cloud.id=5b2de169-2785-441b-ae8c-186a1936b17d',
+ '--xpack.cloud.chat.enabled=true',
+ `--xpack.cloud.chat.chatURL=${CHAT_URL}`,
+ `--xpack.cloud.chatIdentitySecret=${CHAT_IDENTITY_SECRET}`,
+ ]
+ : []),
],
},
uiSettings: {
diff --git a/x-pack/test/cloud_integration/tests/chat.ts b/x-pack/test/cloud_integration/tests/chat.ts
new file mode 100644
index 0000000000000..3411ebc8174a6
--- /dev/null
+++ b/x-pack/test/cloud_integration/tests/chat.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import expect from '@kbn/expect';
+import { FtrProviderContext } from '../ftr_provider_context';
+
+export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const find = getService('find');
+ const PageObjects = getPageObjects(['common']);
+
+ describe('Cloud Chat integration', function () {
+ before(async () => {
+ // Create role mapping so user gets superuser access
+ await getService('esSupertest')
+ .post('/_security/role_mapping/saml1')
+ .send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
+ .expect(200);
+ });
+
+ it('chat widget is present when enabled', async () => {
+ PageObjects.common.navigateToUrl('integrations', 'browse', { useActualUrl: true });
+ const chat = await find.byCssSelector('[data-test-subj="floatingChatTrigger"]', 20000);
+ expect(chat).to.not.be(null);
+ });
+ });
+}