diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts index edd69fa626b1..b3962a7c88cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/__mocks__/content_sources.mock.ts @@ -227,6 +227,15 @@ export const sourceConfigData = { }, }; +export const oauthApplication = { + name: 'app', + uid: '123uid123', + secret: 'shhhhhhhhh', + redirectUri: 'https://foo', + confidential: false, + nativeRedirectUri: 'https://bar', +}; + export const exampleResult = { sourceName: 'source', searchResultConfig: { diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx index 944820c4b1c4..8a83e9aad5fd 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/layout/nav.tsx @@ -24,9 +24,14 @@ import { interface Props { sourcesSubNav?: React.ReactNode; groupsSubNav?: React.ReactNode; + settingsSubNav?: React.ReactNode; } -export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNav }) => ( +export const WorkplaceSearchNav: React.FC = ({ + sourcesSubNav, + groupsSubNav, + settingsSubNav, +}) => ( {NAV.OVERVIEW} @@ -43,7 +48,7 @@ export const WorkplaceSearchNav: React.FC = ({ sourcesSubNav, groupsSubNa {NAV.SECURITY} - + {NAV.SETTINGS} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts new file mode 100644 index 000000000000..706dc1d75d9c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LicenseCallout } from './license_callout'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx new file mode 100644 index 000000000000..b3bbd850be50 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiLink, EuiText } from '@elastic/eui'; + +import { LicenseCallout } from '.'; + +describe('LicenseCallout', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLink)).toHaveLength(1); + expect(wrapper.find(EuiText)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.tsx new file mode 100644 index 000000000000..166925c50232 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/license_callout/license_callout.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLink, EuiFlexItem, EuiFlexGroup, EuiText } from '@elastic/eui'; + +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; + +interface LicenseCalloutProps { + message?: string; +} + +export const LicenseCallout: React.FC = ({ message }) => { + const title = ( + <> + {message}{' '} + + Explore Platinum features + + + ); + + return ( +
+ + +
+ +
+
+ + {title} + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index 4ca256ac91a3..6eedc9270b83 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -43,6 +43,21 @@ export const NAV = { SETTINGS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settings', { defaultMessage: 'Settings', }), + SETTINGS_CUSTOMIZE: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nav.settingsCustomize', + { + defaultMessage: 'Customize', + } + ), + SETTINGS_SOURCE_PRIORITIZATION: i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.nav.settingsSourcePrioritization', + { + defaultMessage: 'Content source connectors', + } + ), + SETTINGS_OAUTH: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.settingsOauth', { + defaultMessage: 'OAuth application', + }), ADD_SOURCE: i18n.translate('xpack.enterpriseSearch.workplaceSearch.nav.addSource', { defaultMessage: 'Add Source', }), @@ -275,43 +290,240 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate( ); export const PUBLIC_KEY_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.publicKey.label', + 'xpack.enterpriseSearch.workplaceSearch.publicKey.label', { defaultMessage: 'Public Key', } ); export const CONSUMER_KEY_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.consumerKey.label', + 'xpack.enterpriseSearch.workplaceSearch.consumerKey.label', { defaultMessage: 'Consumer Key', } ); export const BASE_URI_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.baseUri.label', + 'xpack.enterpriseSearch.workplaceSearch.baseUri.label', { defaultMessage: 'Base URI', } ); export const BASE_URL_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.baseUrl.label', + 'xpack.enterpriseSearch.workplaceSearch.baseUrl.label', { defaultMessage: 'Base URL', } ); export const CLIENT_ID_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.clientId.label', + 'xpack.enterpriseSearch.workplaceSearch.clientId.label', { defaultMessage: 'Client id', } ); export const CLIENT_SECRET_LABEL = i18n.translate( - 'xpack.enterpriseSearch.workplaceSearc.clientSecret.label', + 'xpack.enterpriseSearch.workplaceSearch.clientSecret.label', { defaultMessage: 'Client secret', } ); + +export const CONFIDENTIAL_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confidential.label', + { + defaultMessage: 'Confidential', + } +); + +export const CONFIDENTIAL_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.confidential.text', + { + defaultMessage: + 'Deselect for environments in which the client secret cannot be kept confidential, such as native mobile apps and single page applications.', + } +); + +export const CREDENTIALS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.credentials.title', + { + defaultMessage: 'Credentials', + } +); + +export const CREDENTIALS_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.credentials.description', + { + defaultMessage: + 'Use the following credentials within your client to request access tokens from our authentication server.', + } +); + +export const ORG_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.orgUpdated.message', + { + defaultMessage: 'Successfully updated organization.', + } +); + +export const OAUTH_APP_UPDATED_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.oauthAppUpdated.message', + { + defaultMessage: 'Successfully updated application.', + } +); + +export const SAVE_CHANGES_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.saveChanges.button', + { + defaultMessage: 'Save changes', + } +); + +export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', { + defaultMessage: 'Name', +}); + +export const OAUTH_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.oauth.description', + { + defaultMessage: 'Create an OAuth client for your organization.', + } +); + +export const OAUTH_PERSISTED_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.oauthPersisted.description', + { + defaultMessage: + "Access your organization's OAuth client credentials and manage OAuth settings.", + } +); + +export const REDIRECT_URIS_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectURIs.label', + { + defaultMessage: 'Redirect URIs', + } +); + +export const REDIRECT_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectHelp.text', + { + defaultMessage: 'Provide one URI per line.', + } +); + +export const REDIRECT_NATIVE_HELP_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectNativeHelp.text', + { + defaultMessage: 'For local development URIs, use format', + } +); + +export const REDIRECT_SECURE_ERROR_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectSecureError.text', + { + defaultMessage: 'Cannot contain duplicate redirect URIs.', + } +); + +export const REDIRECT_INSECURE_ERROR_TEXT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.redirectInsecureError.text', + { + defaultMessage: 'Using an insecure redirect URI (http) is not recommended.', + } +); + +export const LICENSE_MODAL_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.title', + { + defaultMessage: 'Configuring OAuth for Custom Search Applications', + } +); + +export const LICENSE_MODAL_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.description', + { + defaultMessage: + 'Configure an OAuth application for secure use of the Workplace Search Search API. Upgrade to a Platinum license to enable the Search API and create your OAuth application.', + } +); + +export const LICENSE_MODAL_LINK = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.licenseModal.link', + { + defaultMessage: 'Explore Platinum features', + } +); + +export const CUSTOMIZE_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.header.title', + { + defaultMessage: 'Customize Workplace Search', + } +); + +export const CUSTOMIZE_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.header.description', + { + defaultMessage: 'Personalize general organization settings.', + } +); + +export const CUSTOMIZE_NAME_LABEL = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.name.label', + { + defaultMessage: 'Personalize general organization settings.', + } +); + +export const CUSTOMIZE_NAME_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.customize.name.button', + { + defaultMessage: 'Save organization name', + } +); + +export const UPDATE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.update.button', + { + defaultMessage: 'Update', + } +); + +export const CONFIGURE_BUTTON = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.configure.button', + { + defaultMessage: 'Configure', + } +); + +export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privatePlatinumCallout.text', + { + defaultMessage: 'Private Sources require a Platinum license.', + } +); + +export const PRIVATE_SOURCE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.privateSource.text', + { + defaultMessage: 'Private Source', + } +); + +export const CONNECTORS_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.connectors.header.title', + { + defaultMessage: 'Content source connectors', + } +); + +export const CONNECTORS_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.connectors.header.description', + { + defaultMessage: 'All of your configurable connectors.', + } +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx index 562a2ffb3288..65a2c7a4a44d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -16,7 +16,13 @@ import { AppLogic } from './app_logic'; import { Layout } from '../shared/layout'; import { WorkplaceSearchNav, WorkplaceSearchHeaderActions } from './components/layout'; -import { GROUPS_PATH, SETUP_GUIDE_PATH, SOURCES_PATH, PERSONAL_SOURCES_PATH } from './routes'; +import { + GROUPS_PATH, + SETUP_GUIDE_PATH, + SOURCES_PATH, + PERSONAL_SOURCES_PATH, + ORG_SETTINGS_PATH, +} from './routes'; import { SetupGuide } from './views/setup_guide'; import { ErrorState } from './views/error_state'; @@ -24,9 +30,11 @@ import { NotFound } from '../shared/not_found'; import { Overview } from './views/overview'; import { GroupsRouter } from './views/groups'; import { SourcesRouter } from './views/content_sources'; +import { SettingsRouter } from './views/settings'; import { GroupSubNav } from './views/groups/components/group_sub_nav'; import { SourceSubNav } from './views/content_sources/components/source_sub_nav'; +import { SettingsSubNav } from './views/settings/components/settings_sub_nav'; export const WorkplaceSearch: React.FC = (props) => { const { config } = useValues(KibanaLogic); @@ -88,6 +96,15 @@ export const WorkplaceSearchConfigured: React.FC = (props) => { + + } />} + restrictWidth + readOnlyMode={readOnlyMode} + > + + + } restrictWidth readOnlyMode={readOnlyMode}> {errorConnecting ? ( diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx new file mode 100644 index 000000000000..0bc3d3dadd09 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.test.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import { configuredSources } from '../../../__mocks__/content_sources.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Loading } from '../../../../shared/loading'; +import { LicenseCallout } from '../../../components/shared/license_callout'; + +import { Connectors } from './connectors'; + +describe('Connectors', () => { + const initializeConnectors = jest.fn(); + + beforeEach(() => { + setMockActions({ initializeConnectors }); + setMockValues({ connectors: configuredSources }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="ConnectorRow"]')).toHaveLength(configuredSources.length); + }); + + it('returns loading when loading', () => { + setMockValues({ + connectors: configuredSources, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('renders LicenseCallout for restricted items', () => { + const wrapper = shallow(); + + const numUnsupportedAccountOnly = configuredSources.filter( + (s) => s.accountContextOnly && !s.supportedByLicense + ).length; + expect(wrapper.find(LicenseCallout)).toHaveLength(numUnsupportedAccountOnly); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx new file mode 100644 index 000000000000..4029809a07fe --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/connectors.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; +import { reject } from 'lodash'; + +import { + EuiBadge, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; + +import { EuiButtonEmptyTo } from '../../../../shared/react_router_helpers'; +import { Loading } from '../../../../shared/loading'; +import { SourceIcon } from '../../../components/shared/source_icon'; +import { LicenseCallout } from '../../../components/shared/license_callout'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; + +import { + CONFIGURE_BUTTON, + CONNECTORS_HEADER_TITLE, + CONNECTORS_HEADER_DESCRIPTION, + CUSTOM_SERVICE_TYPE, + PRIVATE_PLATINUM_LICENSE_CALLOUT, + PRIVATE_SOURCE, + UPDATE_BUTTON, +} from '../../../constants'; +import { getSourcesPath } from '../../../routes'; +import { SourceDataItem } from '../../../types'; + +import { staticSourceData } from '../../content_sources/source_data'; + +import { SettingsLogic } from '../settings_logic'; + +export const Connectors: React.FC = () => { + const { initializeConnectors } = useActions(SettingsLogic); + const { dataLoading, connectors } = useValues(SettingsLogic); + + useEffect(() => { + initializeConnectors(); + }, []); + + if (dataLoading) return ; + + const availableConnectors = reject( + connectors, + ({ serviceType }) => serviceType === CUSTOM_SERVICE_TYPE + ); + + const getRowActions = (configured: boolean, serviceType: string, supportedByLicense: boolean) => { + const { addPath, editPath } = staticSourceData.find( + (s) => s.serviceType === serviceType + ) as SourceDataItem; + const configurePath = getSourcesPath(addPath, true); + + const updateButtons = ( + + + + {UPDATE_BUTTON} + + + + ); + + const configureButton = supportedByLicense ? ( + + {CONFIGURE_BUTTON} + + ) : ( + + {CONFIGURE_BUTTON} + + ); + + return configured ? updateButtons : configureButton; + }; + + const platinumLicenseCallout = ; + + const connectorsList = ( + + {availableConnectors.map( + ({ serviceType, name, configured, accountContextOnly, supportedByLicense }) => ( + + + + + + + + + + + {name} +    + {accountContextOnly && {PRIVATE_SOURCE}} + + + + + + {getRowActions(configured, serviceType, supportedByLicense)} + + + {accountContextOnly && !supportedByLicense && ( + + + + {platinumLicenseCallout} + + + )} + + ) + )} + + ); + + return ( + <> + + {connectorsList} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx new file mode 100644 index 000000000000..4db0a60b75ee --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiFieldText } from '@elastic/eui'; + +import { ContentSection } from '../../../components/shared/content_section'; + +import { Customize } from './customize'; + +describe('Customize', () => { + const onOrgNameInputChange = jest.fn(); + const updateOrgName = jest.fn(); + + beforeEach(() => { + setMockActions({ onOrgNameInputChange, updateOrgName }); + setMockValues({ orgNameInputValue: '' }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ContentSection)).toHaveLength(1); + }); + + it('handles input change', () => { + const wrapper = shallow(); + const input = wrapper.find(EuiFieldText); + input.simulate('change', { target: { value: 'foobar' } }); + + expect(onOrgNameInputChange).toHaveBeenCalledWith('foobar'); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(updateOrgName).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx new file mode 100644 index 000000000000..b87e00965f55 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/customize.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiButton, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; + +import { + CUSTOMIZE_HEADER_TITLE, + CUSTOMIZE_HEADER_DESCRIPTION, + CUSTOMIZE_NAME_LABEL, + CUSTOMIZE_NAME_BUTTON, +} from '../../../constants'; + +import { ContentSection } from '../../../components/shared/content_section'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; + +import { SettingsLogic } from '../settings_logic'; + +export const Customize: React.FC = () => { + const { onOrgNameInputChange, updateOrgName } = useActions(SettingsLogic); + const { orgNameInputValue } = useValues(SettingsLogic); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + updateOrgName(); + }; + + return ( +
+ + + + + + onOrgNameInputChange(e.target.value)} + /> + + + + + + + {CUSTOMIZE_NAME_BUTTON} + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx new file mode 100644 index 000000000000..ec831492ee90 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.test.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiModal, EuiForm } from '@elastic/eui'; + +import { oauthApplication } from '../../../__mocks__/content_sources.mock'; +import { OAUTH_DESCRIPTION, REDIRECT_INSECURE_ERROR_TEXT } from '../../../constants'; + +import { CredentialItem } from '../../../components/shared/credential_item'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { OauthApplication } from './oauth_application'; + +describe('OauthApplication', () => { + const setOauthApplication = jest.fn(); + const updateOauthApplication = jest.fn(); + + beforeEach(() => { + setMockValues({ hasPlatinumLicense: true, oauthApplication }); + setMockActions({ setOauthApplication, updateOauthApplication }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiForm)).toHaveLength(1); + }); + + it('does not render when no oauthApp', () => { + setMockValues({ hasPlatinumLicense: true, oauthApplication: undefined }); + const wrapper = shallow(); + + expect(wrapper.find(EuiForm)).toHaveLength(0); + }); + + it('handles OAuthAppName change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="OAuthAppName"]'); + input.simulate('change', { target: { value: 'foo' } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ ...oauthApplication, name: 'foo' }); + }); + + it('handles RedirectURIsTextArea change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="RedirectURIsTextArea"]'); + input.simulate('change', { target: { value: 'bar' } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ + ...oauthApplication, + redirectUri: 'bar', + }); + }); + + it('handles ConfidentialToggle change', () => { + const wrapper = shallow(); + const input = wrapper.find('[data-test-subj="ConfidentialToggle"]'); + input.simulate('change', { target: { checked: true } }); + + expect(setOauthApplication).toHaveBeenCalledWith({ + ...oauthApplication, + confidential: true, + }); + }); + + it('handles form submission', () => { + const wrapper = shallow(); + const preventDefault = jest.fn(); + wrapper.find('form').simulate('submit', { preventDefault }); + + expect(updateOauthApplication).toHaveBeenCalled(); + }); + + it('renders ClientSecret on confidential item', () => { + setMockValues({ + hasPlatinumLicense: true, + oauthApplication: { + ...oauthApplication, + confidential: true, + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(CredentialItem)).toHaveLength(2); + }); + + it('renders license modal', () => { + setMockValues({ + hasPlatinumLicense: false, + oauthApplication, + }); + const wrapper = shallow(); + + expect(wrapper.find(EuiModal)).toHaveLength(1); + }); + + it('closes license modal', () => { + setMockValues({ + hasPlatinumLicense: false, + oauthApplication, + }); + const wrapper = shallow(); + wrapper.find(EuiModal).prop('onClose')(); + + expect(wrapper.find(EuiModal)).toHaveLength(0); + }); + + it('handles conditional copy', () => { + setMockValues({ + hasPlatinumLicense: true, + oauthApplication: { + ...oauthApplication, + uid: undefined, + redirectUri: 'http://foo', + }, + }); + const wrapper = shallow(); + + expect(wrapper.find(ViewContentHeader).prop('description')).toEqual(OAUTH_DESCRIPTION); + expect(wrapper.find('[data-test-subj="RedirectURIsRow"]').prop('error')).toEqual( + REDIRECT_INSECURE_ERROR_TEXT + ); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx new file mode 100644 index 000000000000..b648b2577963 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/oauth_application.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FormEvent, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiTextArea, + EuiSwitch, + EuiCode, + EuiSpacer, + EuiOverlayMask, + EuiLink, + EuiModal, + EuiModalBody, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { ENT_SEARCH_LICENSE_MANAGEMENT } from '../../../routes'; +import { + CLIENT_ID_LABEL, + CLIENT_SECRET_LABEL, + CONFIDENTIAL_HELP_TEXT, + CONFIDENTIAL_LABEL, + CREDENTIALS_TITLE, + CREDENTIALS_DESCRIPTION, + NAME_LABEL, + NAV, + OAUTH_DESCRIPTION, + OAUTH_PERSISTED_DESCRIPTION, + REDIRECT_HELP_TEXT, + REDIRECT_NATIVE_HELP_TEXT, + REDIRECT_INSECURE_ERROR_TEXT, + REDIRECT_SECURE_ERROR_TEXT, + REDIRECT_URIS_LABEL, + SAVE_CHANGES_BUTTON, + LICENSE_MODAL_TITLE, + LICENSE_MODAL_DESCRIPTION, + LICENSE_MODAL_LINK, +} from '../../../constants'; + +import { LicensingLogic } from '../../../../shared/licensing'; +import { ContentSection } from '../../../components/shared/content_section'; +import { LicenseBadge } from '../../../components/shared/license_badge'; +import { ViewContentHeader } from '../../../components/shared/view_content_header'; +import { CredentialItem } from '../../../components/shared/credential_item'; +import { SettingsLogic } from '../settings_logic'; + +export const OauthApplication: React.FC = () => { + const { setOauthApplication, updateOauthApplication } = useActions(SettingsLogic); + const { oauthApplication } = useValues(SettingsLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); + + const [isLicenseModalVisible, setIsLicenseModalVisible] = useState(!hasPlatinumLicense); + const closeLicenseModal = () => setIsLicenseModalVisible(false); + + if (!oauthApplication) return null; + + const persisted = !!(oauthApplication.uid && oauthApplication.secret); + const description = persisted ? OAUTH_PERSISTED_DESCRIPTION : OAUTH_DESCRIPTION; + const insecureRedirectUri = /(^|\s)http:/i.test(oauthApplication.redirectUri); + const redirectUris = oauthApplication.redirectUri.split('\n').map((uri) => uri.trim()); + const uniqRedirectUri = Array.from(new Set(redirectUris)); + const redirectUriInvalid = insecureRedirectUri || redirectUris.length !== uniqRedirectUri.length; + + const redirectUriHelpText = ( + + {REDIRECT_HELP_TEXT}{' '} + {oauthApplication.nativeRedirectUri && ( + + {REDIRECT_NATIVE_HELP_TEXT} {oauthApplication.nativeRedirectUri} + + )} + + ); + + const redirectErrorText = insecureRedirectUri + ? REDIRECT_INSECURE_ERROR_TEXT + : REDIRECT_SECURE_ERROR_TEXT; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + updateOauthApplication(); + }; + + const licenseModal = ( + + + + + + + +

{LICENSE_MODAL_TITLE}

+
+ + {LICENSE_MODAL_DESCRIPTION} + + + {LICENSE_MODAL_LINK} + + +
+
+
+ ); + + return ( + <> +
+ + + + + setOauthApplication({ ...oauthApplication, name: e.target.value })} + required + disabled={!hasPlatinumLicense} + /> + + + + + setOauthApplication({ ...oauthApplication, redirectUri: e.target.value }) + } + required + disabled={!hasPlatinumLicense} + /> + + + + + setOauthApplication({ ...oauthApplication, confidential: e.target.checked }) + } + disabled={!hasPlatinumLicense} + /> + + + + {SAVE_CHANGES_BUTTON} + + + {persisted && ( + + + + + + {oauthApplication.confidential && ( + <> + + + + + + )} + + )} + +
+ + {isLicenseModalVisible && licenseModal} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.tsx new file mode 100644 index 000000000000..3a2d4833da68 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { SettingsSubNav } from './settings_sub_nav'; + +describe('SettingsSubNav', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SideNavLink)).toHaveLength(3); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx new file mode 100644 index 000000000000..794718044b9f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/settings_sub_nav.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { NAV } from '../../../constants'; + +import { SideNavLink } from '../../../../shared/layout'; + +import { + ORG_SETTINGS_CUSTOMIZE_PATH, + ORG_SETTINGS_CONNECTORS_PATH, + ORG_SETTINGS_OAUTH_APPLICATION_PATH, +} from '../../../routes'; + +export const SettingsSubNav: React.FC = () => ( + <> + {NAV.SETTINGS_CUSTOMIZE} + + {NAV.SETTINGS_SOURCE_PRIORITIZATION} + + {NAV.SETTINGS_OAUTH} + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx new file mode 100644 index 000000000000..2ecbc2502502 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_useeffect.mock'; + +import { setMockValues, setMockActions } from '../../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiConfirmModal } from '@elastic/eui'; + +import { sourceConfigData } from '../../../__mocks__/content_sources.mock'; + +import { Loading } from '../../../../shared/loading'; +import { SaveConfig } from '../../content_sources/components/add_source/save_config'; +import { SourceConfig } from './source_config'; + +describe('SourceConfig', () => { + const deleteSourceConfig = jest.fn(); + const getSourceConfigData = jest.fn(); + const saveSourceConfig = jest.fn(); + + beforeEach(() => { + setMockValues({ sourceConfigData, dataLoading: false }); + setMockActions({ deleteSourceConfig, getSourceConfigData, saveSourceConfig }); + }); + + it('renders', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); + }); + + it('returns loading when loading', () => { + setMockValues({ + sourceConfigData, + dataLoading: true, + }); + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('handles delete click', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + wrapper.find(EuiConfirmModal).prop('onConfirm')!({} as any); + + expect(deleteSourceConfig).toHaveBeenCalled(); + }); + + it('saves source config', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + saveConfig.prop('advanceStep')!(); + + expect(saveSourceConfig).toHaveBeenCalled(); + }); + + it('cancels and closes modal', () => { + const wrapper = shallow(); + const saveConfig = wrapper.find(SaveConfig); + + // Trigger modal visibility + saveConfig.prop('onDeleteConfig')!(); + + wrapper.find(EuiConfirmModal).prop('onCancel')!({} as any); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx new file mode 100644 index 000000000000..52e87311284f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/components/source_config.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; + +import { useActions, useValues } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; + +import { Loading } from '../../../../shared/loading'; +import { SourceDataItem } from '../../../types'; +import { staticSourceData } from '../../content_sources/source_data'; +import { SourceLogic } from '../../content_sources/source_logic'; +import { AddSourceLogic } from '../../content_sources/components/add_source/add_source_logic'; + +import { AddSourceHeader } from '../../content_sources/components/add_source/add_source_header'; +import { SaveConfig } from '../../content_sources/components/add_source/save_config'; + +import { SettingsLogic } from '../settings_logic'; + +interface SourceConfigProps { + sourceIndex: number; +} + +export const SourceConfig: React.FC = ({ sourceIndex }) => { + const [confirmModalVisible, setConfirmModalVisibility] = useState(false); + const { configuration, serviceType } = staticSourceData[sourceIndex] as SourceDataItem; + const { deleteSourceConfig } = useActions(SettingsLogic); + const { getSourceConfigData } = useActions(SourceLogic); + const { saveSourceConfig } = useActions(AddSourceLogic); + const { + sourceConfigData: { name, categories }, + dataLoading: sourceDataLoading, + } = useValues(SourceLogic); + + useEffect(() => { + getSourceConfigData(serviceType); + }, []); + + if (sourceDataLoading) return ; + const hideConfirmModal = () => setConfirmModalVisibility(false); + const showConfirmModal = () => setConfirmModalVisibility(true); + const saveUpdatedConfig = () => saveSourceConfig(true); + + const header = ; + + return ( + <> + + {confirmModalVisible && ( + + deleteSourceConfig(serviceType, name)} + onCancel={hideConfirmModal} + buttonColor="danger" + > + {i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.settings.confirmRemoveConfig.message', + { + defaultMessage: + 'Are you sure you want to remove the OAuth configuration for {name}?', + values: { name }, + } + )} + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts new file mode 100644 index 000000000000..cfea0684c105 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SettingsRouter } from './settings_router'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts new file mode 100644 index 000000000000..aaeae08d552d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.test.ts @@ -0,0 +1,234 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LogicMounter } from '../../../__mocks__/kea.mock'; + +import { + mockFlashMessageHelpers, + mockHttpValues, + expectedAsyncError, + mockKibanaValues, +} from '../../../__mocks__'; + +import { configuredSources, oauthApplication } from '../../__mocks__/content_sources.mock'; + +import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; +import { SettingsLogic } from './settings_logic'; + +describe('SettingsLogic', () => { + const { http } = mockHttpValues; + const { navigateToUrl } = mockKibanaValues; + const { + clearFlashMessages, + flashAPIErrors, + setSuccessMessage, + setQueuedSuccessMessage, + } = mockFlashMessageHelpers; + const { mount } = new LogicMounter(SettingsLogic); + const ORG_NAME = 'myOrg'; + const defaultValues = { + dataLoading: true, + connectors: [], + orgNameInputValue: '', + oauthApplication: null, + }; + const serverProps = { organizationName: ORG_NAME, oauthApplication }; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(SettingsLogic.values).toEqual(defaultValues); + }); + + describe('actions', () => { + it('onInitializeConnectors', () => { + SettingsLogic.actions.onInitializeConnectors(configuredSources); + }); + + it('onOrgNameInputChange', () => { + const NAME = 'foo'; + SettingsLogic.actions.onOrgNameInputChange(NAME); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(NAME); + }); + + it('setUpdatedName', () => { + const NAME = 'bar'; + SettingsLogic.actions.setUpdatedName({ organizationName: NAME }); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(NAME); + }); + + it('setServerProps', () => { + SettingsLogic.actions.setServerProps(serverProps); + + expect(SettingsLogic.values.orgNameInputValue).toEqual(ORG_NAME); + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + + it('setOauthApplication', () => { + SettingsLogic.actions.setOauthApplication(oauthApplication); + + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + + it('setUpdatedOauthApplication', () => { + SettingsLogic.actions.setUpdatedOauthApplication({ oauthApplication }); + + expect(SettingsLogic.values.oauthApplication).toEqual(oauthApplication); + }); + }); + + describe('listeners', () => { + describe('initializeSettings', () => { + it('calls API and sets values', async () => { + const setServerPropsSpy = jest.spyOn(SettingsLogic.actions, 'setServerProps'); + const promise = Promise.resolve(configuredSources); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeSettings(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings'); + await promise; + expect(setServerPropsSpy).toHaveBeenCalledWith(configuredSources); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeSettings(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('initializeConnectors', () => { + it('calls API and sets values', async () => { + const onInitializeConnectorsSpy = jest.spyOn( + SettingsLogic.actions, + 'onInitializeConnectors' + ); + const promise = Promise.resolve(serverProps); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeConnectors(); + + expect(http.get).toHaveBeenCalledWith('/api/workplace_search/org/settings/connectors'); + await promise; + expect(onInitializeConnectorsSpy).toHaveBeenCalledWith(serverProps); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.get.mockReturnValue(promise); + SettingsLogic.actions.initializeConnectors(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOrgName', () => { + it('calls API and sets values', async () => { + const NAME = 'updated name'; + SettingsLogic.actions.onOrgNameInputChange(NAME); + const setUpdatedNameSpy = jest.spyOn(SettingsLogic.actions, 'setUpdatedName'); + const promise = Promise.resolve({ organizationName: NAME }); + http.put.mockReturnValue(promise); + + SettingsLogic.actions.updateOrgName(); + + expect(http.put).toHaveBeenCalledWith('/api/workplace_search/org/settings/customize', { + body: JSON.stringify({ name: NAME }), + }); + await promise; + expect(setSuccessMessage).toHaveBeenCalledWith(ORG_UPDATED_MESSAGE); + expect(setUpdatedNameSpy).toHaveBeenCalledWith({ organizationName: NAME }); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.put.mockReturnValue(promise); + SettingsLogic.actions.updateOrgName(); + + await expectedAsyncError(promise); + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('updateOauthApplication', () => { + it('calls API and sets values', async () => { + const { name, redirectUri, confidential } = oauthApplication; + const setUpdatedOauthApplicationSpy = jest.spyOn( + SettingsLogic.actions, + 'setUpdatedOauthApplication' + ); + const promise = Promise.resolve({ oauthApplication }); + http.put.mockReturnValue(promise); + SettingsLogic.actions.setOauthApplication(oauthApplication); + SettingsLogic.actions.updateOauthApplication(); + + expect(clearFlashMessages).toHaveBeenCalled(); + + expect(http.put).toHaveBeenCalledWith( + '/api/workplace_search/org/settings/oauth_application', + { + body: JSON.stringify({ + oauth_application: { name, confidential, redirect_uri: redirectUri }, + }), + } + ); + await promise; + expect(setUpdatedOauthApplicationSpy).toHaveBeenCalledWith({ oauthApplication }); + expect(setSuccessMessage).toHaveBeenCalledWith(OAUTH_APP_UPDATED_MESSAGE); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.put.mockReturnValue(promise); + SettingsLogic.actions.updateOauthApplication(); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + describe('deleteSourceConfig', () => { + const SERVICE_TYPE = 'github'; + const NAME = 'baz'; + + it('calls API and sets values', async () => { + const promise = Promise.resolve({}); + http.delete.mockReturnValue(promise); + SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); + + await promise; + expect(navigateToUrl).toHaveBeenCalledWith('/settings/connectors'); + expect(setQueuedSuccessMessage).toHaveBeenCalled(); + }); + + it('handles error', async () => { + const promise = Promise.reject('this is an error'); + http.delete.mockReturnValue(promise); + SettingsLogic.actions.deleteSourceConfig(SERVICE_TYPE, NAME); + await expectedAsyncError(promise); + + expect(flashAPIErrors).toHaveBeenCalledWith('this is an error'); + }); + }); + + it('resetSettingsState', () => { + // needed to set dataLoading to false + SettingsLogic.actions.onInitializeConnectors(configuredSources); + SettingsLogic.actions.resetSettingsState(); + + expect(clearFlashMessages).toHaveBeenCalled(); + expect(SettingsLogic.values.dataLoading).toEqual(true); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts new file mode 100644 index 000000000000..b5370ec09f67 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_logic.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { kea, MakeLogicType } from 'kea'; +import { i18n } from '@kbn/i18n'; + +import { + clearFlashMessages, + setQueuedSuccessMessage, + setSuccessMessage, + flashAPIErrors, +} from '../../../shared/flash_messages'; +import { KibanaLogic } from '../../../shared/kibana'; +import { HttpLogic } from '../../../shared/http'; + +import { Connector } from '../../types'; +import { ORG_UPDATED_MESSAGE, OAUTH_APP_UPDATED_MESSAGE } from '../../constants'; + +import { ORG_SETTINGS_CONNECTORS_PATH } from '../../routes'; + +interface IOauthApplication { + name: string; + uid: string; + secret: string; + redirectUri: string; + confidential: boolean; + nativeRedirectUri: string; +} + +export interface SettingsServerProps { + organizationName: string; + oauthApplication: IOauthApplication; +} + +interface SettingsActions { + onInitializeConnectors(connectors: Connector[]): Connector[]; + onOrgNameInputChange(orgNameInputValue: string): string; + setUpdatedName({ organizationName }: { organizationName: string }): string; + setServerProps(props: SettingsServerProps): SettingsServerProps; + setOauthApplication(oauthApplication: IOauthApplication): IOauthApplication; + setUpdatedOauthApplication({ + oauthApplication, + }: { + oauthApplication: IOauthApplication; + }): IOauthApplication; + resetSettingsState(): void; + initializeSettings(): void; + initializeConnectors(): void; + updateOauthApplication(): void; + updateOrgName(): void; + deleteSourceConfig( + serviceType: string, + name: string + ): { + serviceType: string; + name: string; + }; +} + +interface SettingsValues { + dataLoading: boolean; + connectors: Connector[]; + orgNameInputValue: string; + oauthApplication: IOauthApplication | null; +} + +export const SettingsLogic = kea>({ + actions: { + onInitializeConnectors: (connectors: Connector[]) => connectors, + onOrgNameInputChange: (orgNameInputValue: string) => orgNameInputValue, + setUpdatedName: ({ organizationName }) => organizationName, + setServerProps: (props: SettingsServerProps) => props, + setOauthApplication: (oauthApplication: IOauthApplication) => oauthApplication, + setUpdatedOauthApplication: ({ oauthApplication }: { oauthApplication: IOauthApplication }) => + oauthApplication, + resetSettingsState: () => true, + initializeSettings: () => true, + initializeConnectors: () => true, + updateOrgName: () => true, + updateOauthApplication: () => true, + deleteSourceConfig: (serviceType: string, name: string) => ({ + serviceType, + name, + }), + }, + reducers: { + connectors: [ + [], + { + onInitializeConnectors: (_, connectors) => connectors, + }, + ], + orgNameInputValue: [ + '', + { + setServerProps: (_, { organizationName }) => organizationName, + onOrgNameInputChange: (_, orgNameInputValue) => orgNameInputValue, + setUpdatedName: (_, organizationName) => organizationName, + }, + ], + oauthApplication: [ + null, + { + setServerProps: (_, { oauthApplication }) => oauthApplication, + setOauthApplication: (_, oauthApplication) => oauthApplication, + setUpdatedOauthApplication: (_, oauthApplication) => oauthApplication, + }, + ], + dataLoading: [ + true, + { + onInitializeConnectors: () => false, + resetSettingsState: () => true, + }, + ], + }, + listeners: ({ actions, values }) => ({ + initializeSettings: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings'; + + try { + const response = await http.get(route); + actions.setServerProps(response); + } catch (e) { + flashAPIErrors(e); + } + }, + initializeConnectors: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/connectors'; + + try { + const response = await http.get(route); + actions.onInitializeConnectors(response); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOrgName: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/customize'; + const { orgNameInputValue: name } = values; + const body = JSON.stringify({ name }); + + try { + const response = await http.put(route, { body }); + actions.setUpdatedName(response); + setSuccessMessage(ORG_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + updateOauthApplication: async () => { + const { http } = HttpLogic.values; + const route = '/api/workplace_search/org/settings/oauth_application'; + const oauthApplication = values.oauthApplication || ({} as IOauthApplication); + const { name, redirectUri, confidential } = oauthApplication; + const body = JSON.stringify({ + oauth_application: { name, confidential, redirect_uri: redirectUri }, + }); + + clearFlashMessages(); + + try { + const response = await http.put(route, { body }); + actions.setUpdatedOauthApplication(response); + setSuccessMessage(OAUTH_APP_UPDATED_MESSAGE); + } catch (e) { + flashAPIErrors(e); + } + }, + deleteSourceConfig: async ({ serviceType, name }) => { + const { http } = HttpLogic.values; + const route = `/api/workplace_search/org/settings/connectors/${serviceType}`; + + try { + await http.delete(route); + KibanaLogic.values.navigateToUrl(ORG_SETTINGS_CONNECTORS_PATH); + setQueuedSuccessMessage( + i18n.translate('xpack.enterpriseSearch.workplaceSearch.settings.configRemoved.message', { + defaultMessage: 'Successfully removed configuration for {name}.', + values: { name }, + }) + ); + } catch (e) { + flashAPIErrors(e); + } + }, + resetSettingsState: () => { + clearFlashMessages(); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.tsx new file mode 100644 index 000000000000..315f6c4561cc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_useeffect.mock'; + +import { setMockActions } from '../../../__mocks__'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { Route, Redirect, Switch } from 'react-router-dom'; + +import { staticSourceData } from '../content_sources/source_data'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Connectors } from './components/connectors'; +import { Customize } from './components/customize'; +import { OauthApplication } from './components/oauth_application'; +import { SourceConfig } from './components/source_config'; + +import { SettingsRouter } from './settings_router'; + +describe('SettingsRouter', () => { + const initializeSettings = jest.fn(); + const NUM_SOURCES = staticSourceData.length; + // Should be 3 routes other than the sources listed Connectors, Customize, & OauthApplication + const NUM_ROUTES = NUM_SOURCES + 3; + + beforeEach(() => { + setMockActions({ initializeSettings }); + }); + + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(FlashMessages)).toHaveLength(1); + expect(wrapper.find(Switch)).toHaveLength(1); + expect(wrapper.find(Route)).toHaveLength(NUM_ROUTES); + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Connectors)).toHaveLength(1); + expect(wrapper.find(Customize)).toHaveLength(1); + expect(wrapper.find(OauthApplication)).toHaveLength(1); + expect(wrapper.find(SourceConfig)).toHaveLength(NUM_SOURCES); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx new file mode 100644 index 000000000000..c5df8b3c7aa2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/settings/settings_router.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; + +import { useActions } from 'kea'; +import { Redirect, Route, Switch } from 'react-router-dom'; + +import { + ORG_SETTINGS_PATH, + ORG_SETTINGS_CUSTOMIZE_PATH, + ORG_SETTINGS_CONNECTORS_PATH, + ORG_SETTINGS_OAUTH_APPLICATION_PATH, +} from '../../routes'; + +import { FlashMessages } from '../../../shared/flash_messages'; + +import { Connectors } from './components/connectors'; +import { Customize } from './components/customize'; +import { OauthApplication } from './components/oauth_application'; +import { SourceConfig } from './components/source_config'; + +import { staticSourceData } from '../content_sources/source_data'; + +import { SettingsLogic } from './settings_logic'; + +export const SettingsRouter: React.FC = () => { + const { initializeSettings } = useActions(SettingsLogic); + + useEffect(() => { + initializeSettings(); + }, []); + + return ( + <> + + + + + + + + + + + + + {staticSourceData.map(({ editPath }, i) => ( + + + + ))} + + + ); +};