Migrate security page (#89720)

* Add server routes for Workplace Search Security page

* Initial copy/paste of component tree

Also update lodash imports and fix default exports

* Update paths

* Remove conditional and passed in flash messages

This is no longer needed with the Kibana syntax. Flash messages are set globally and only render when present.

* Replace removed ConfirmModal

In Kibana, we use the Eui components directly

* Remove legacy AppView and sidenav

* Clear flash messages globally

* Update server routes

* Replace Rails http with kibana http

* Add setSourceRestriction action to app_logic

It is used in security_logic

* Add missing typings

* Add route and update nav

* Use internal tools for determining license

* Remove Prompt as it doesn't work in Kibana

There is an error that recommends using AppMountParameters.onAppLeave
instead, but it doesn't cover the case where a user navigates
within the app. We'll revisit this problem later.

* Add i18n

Also refactor PrivateSourcesTable to use static i18n strings.

Before we were using 'remote' and 'standard' as both enums and parts of copy, i.e. "Enable {sourceType} private sources".

But with i18n we can no longer do this. So I made a refactoring to separate these concerns. Now 'remote' and 'standard' are only used as enums. What i18n string to show is defined based on isRemote variable.

* Add components unit tests

* Add logic unit tests

* Remove redundant imports

* Use nextTick instead of awaiting for promises

* Update logic tests to use new mockHelpers
This commit is contained in:
Vadim Yakhin 2021-01-29 16:31:06 -04:00 committed by GitHub
parent 4e18fd8a51
commit e866db7de0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1220 additions and 3 deletions

View file

@ -21,6 +21,7 @@ interface AppValues extends WorkplaceSearchInitialData {
interface AppActions {
initializeAppData(props: InitialAppData): InitialAppData;
setContext(isOrganization: boolean): boolean;
setSourceRestriction(canCreatePersonalSources: boolean): boolean;
}
const emptyOrg = {} as Organization;
@ -34,6 +35,7 @@ export const AppLogic = kea<MakeLogicType<AppValues, AppActions>>({
isFederatedAuth,
}),
setContext: (isOrganization) => isOrganization,
setSourceRestriction: (canCreatePersonalSources: boolean) => canCreatePersonalSources,
},
reducers: {
hasInitialized: [
@ -64,6 +66,10 @@ export const AppLogic = kea<MakeLogicType<AppValues, AppActions>>({
emptyAccount,
{
initializeAppData: (_, { workplaceSearch }) => workplaceSearch?.account || emptyAccount,
setSourceRestriction: (state, canCreatePersonalSources) => ({
...state,
canCreatePersonalSources,
}),
},
],
},

View file

@ -45,9 +45,7 @@ export const WorkplaceSearchNav: React.FC<Props> = ({
<SideNavLink isExternal to={getWorkplaceSearchUrl(`#${ROLE_MAPPINGS_PATH}`)}>
{NAV.ROLE_MAPPINGS}
</SideNavLink>
<SideNavLink isExternal to={getWorkplaceSearchUrl(`#${SECURITY_PATH}`)}>
{NAV.SECURITY}
</SideNavLink>
<SideNavLink to={SECURITY_PATH}>{NAV.SECURITY}</SideNavLink>
<SideNavLink subNav={settingsSubNav} to={ORG_SETTINGS_PATH}>
{NAV.SETTINGS}
</SideNavLink>

View file

@ -289,6 +289,87 @@ export const DOCUMENTATION_LINK_TITLE = i18n.translate(
}
);
export const PRIVATE_SOURCES_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.privateSources.description',
{
defaultMessage:
'Private sources are connected by users in your organization to create a personalized search experience.',
}
);
export const PRIVATE_SOURCES_TOGGLE_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesToggle.description',
{
defaultMessage: 'Enable private sources for your organization',
}
);
export const REMOTE_SOURCES_TOGGLE_TEXT = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesToggle.text',
{
defaultMessage: 'Enable remote private sources',
}
);
export const REMOTE_SOURCES_TABLE_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesTable.description',
{
defaultMessage:
'Remote sources synchronize and store a limited amount of data on disk, with a low impact on storage resources.',
}
);
export const REMOTE_SOURCES_EMPTY_TABLE_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.title',
{
defaultMessage: 'No remote private sources configured yet',
}
);
export const STANDARD_SOURCES_TOGGLE_TEXT = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesToggle.text',
{
defaultMessage: 'Enable standard private sources',
}
);
export const STANDARD_SOURCES_TABLE_DESCRIPTION = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesTable.description',
{
defaultMessage:
'Standard sources synchronize and store all searchable data on disk, with a directly correlated impact on storage resources.',
}
);
export const STANDARD_SOURCES_EMPTY_TABLE_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.title',
{
defaultMessage: 'No standard private sources configured yet',
}
);
export const SECURITY_UNSAVED_CHANGES_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.unsavedChanges.message',
{
defaultMessage:
'Your private sources settings have not been saved. Are you sure you want to leave?',
}
);
export const PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.privateSourcesUpdateConfirmation.text',
{
defaultMessage: 'Updates to private source configuration will take effect immediately.',
}
);
export const SOURCE_RESTRICTIONS_SUCCESS_MESSAGE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.sourceRestrictionsSuccess.message',
{
defaultMessage: 'Successfully updated source restrictions.',
}
);
export const PUBLIC_KEY_LABEL = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.publicKey.label',
{
@ -382,6 +463,20 @@ export const SAVE_CHANGES_BUTTON = i18n.translate(
}
);
export const SAVE_SETTINGS_BUTTON = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.saveSettings.button',
{
defaultMessage: 'Save settings',
}
);
export const KEEP_EDITING_BUTTON = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.keepEditing.button',
{
defaultMessage: 'Keep editing',
}
);
export const NAME_LABEL = i18n.translate('xpack.enterpriseSearch.workplaceSearch.name.label', {
defaultMessage: 'Name',
});
@ -493,6 +588,10 @@ export const UPDATE_BUTTON = i18n.translate(
}
);
export const RESET_BUTTON = i18n.translate('xpack.enterpriseSearch.workplaceSearch.reset.button', {
defaultMessage: 'Reset',
});
export const CONFIGURE_BUTTON = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.configure.button',
{
@ -522,6 +621,10 @@ export const PRIVATE_PLATINUM_LICENSE_CALLOUT = i18n.translate(
}
);
export const SOURCE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.source.text', {
defaultMessage: 'Source',
});
export const PRIVATE_SOURCE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.privateSource.text',
{
@ -529,6 +632,20 @@ export const PRIVATE_SOURCE = i18n.translate(
}
);
export const PRIVATE_SOURCES = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.privateSources.text',
{
defaultMessage: 'Private Sources',
}
);
export const CONFIRM_CHANGES_TEXT = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.confirmChanges.text',
{
defaultMessage: 'Confirm changes',
}
);
export const CONNECTORS_HEADER_TITLE = i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.connectors.header.title',
{

View file

@ -22,6 +22,7 @@ import {
SOURCES_PATH,
PERSONAL_SOURCES_PATH,
ORG_SETTINGS_PATH,
SECURITY_PATH,
} from './routes';
import { SetupGuide } from './views/setup_guide';
@ -29,6 +30,7 @@ import { ErrorState } from './views/error_state';
import { NotFound } from '../shared/not_found';
import { Overview } from './views/overview';
import { GroupsRouter } from './views/groups';
import { Security } from './views/security';
import { SourcesRouter } from './views/content_sources';
import { SettingsRouter } from './views/settings';
@ -102,6 +104,11 @@ export const WorkplaceSearchConfigured: React.FC<InitialAppData> = (props) => {
<GroupsRouter />
</Layout>
</Route>
<Route path={SECURITY_PATH}>
<Layout navigation={<WorkplaceSearchNav />} restrictWidth readOnlyMode={readOnlyMode}>
<Security />
</Layout>
</Route>
<Route path={ORG_SETTINGS_PATH}>
<Layout
navigation={<WorkplaceSearchNav settingsSubNav={<SettingsSubNav />} />}

View file

@ -0,0 +1,54 @@
/*
* 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 { setMockValues } from '../../../../__mocks__';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiSwitch } from '@elastic/eui';
import { PrivateSourcesTable } from './private_sources_table';
describe('PrivateSourcesTable', () => {
beforeEach(() => {
setMockValues({ hasPlatinumLicense: true, isEnabled: true });
});
const props = {
sourceSection: { isEnabled: true, contentSources: [] },
updateSource: jest.fn(),
updateEnabled: jest.fn(),
};
it('renders', () => {
const wrapper = shallow(<PrivateSourcesTable {...props} sourceType="standard" />);
expect(wrapper.find(EuiSwitch)).toHaveLength(1);
});
it('handles switches clicks', () => {
const wrapper = shallow(
<PrivateSourcesTable
{...props}
sourceSection={{
isEnabled: false,
contentSources: [{ id: 'gmail', isEnabled: true, name: 'Gmail' }],
}}
sourceType="remote"
/>
);
const sectionSwitch = wrapper.find(EuiSwitch).first();
const sourceSwitch = wrapper.find(EuiSwitch).last();
const event = { target: { value: true } };
sectionSwitch.prop('onChange')(event as any);
sourceSwitch.prop('onChange')(event as any);
expect(props.updateEnabled).toHaveBeenCalled();
expect(props.updateSource).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,182 @@
/*
* 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 classNames from 'classnames';
import { useValues } from 'kea';
import {
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiText,
EuiTable,
EuiTableBody,
EuiTableHeader,
EuiTableHeaderCell,
EuiTableRow,
EuiTableRowCell,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { LicensingLogic } from '../../../../shared/licensing';
import { SecurityLogic, PrivateSourceSection } from '../security_logic';
import {
REMOTE_SOURCES_TOGGLE_TEXT,
REMOTE_SOURCES_TABLE_DESCRIPTION,
REMOTE_SOURCES_EMPTY_TABLE_TITLE,
STANDARD_SOURCES_TOGGLE_TEXT,
STANDARD_SOURCES_TABLE_DESCRIPTION,
STANDARD_SOURCES_EMPTY_TABLE_TITLE,
SOURCE,
} from '../../../constants';
interface PrivateSourcesTableProps {
sourceType: 'remote' | 'standard';
sourceSection: PrivateSourceSection;
updateSource(sourceId: string, isEnabled: boolean): void;
updateEnabled(isEnabled: boolean): void;
}
const REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION = (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.description"
defaultMessage="Once configured, remote private sources are {enabledStrong}, and users can immediately connect the source from their Personal Dashboard."
values={{
enabledStrong: (
<strong>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.remoteSourcesEmptyTable.enabledStrong',
{ defaultMessage: 'enabled by default' }
)}
</strong>
),
}}
/>
);
const STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION = (
<FormattedMessage
id="xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.description"
defaultMessage="Once configured, standard private sources are {notEnabledStrong}, and must be activated before users are allowed to connect the source from their Personal Dashboard."
values={{
notEnabledStrong: (
<strong>
{i18n.translate(
'xpack.enterpriseSearch.workplaceSearch.security.standardSourcesEmptyTable.notEnabledStrong',
{ defaultMessage: 'not enabled by default' }
)}
</strong>
),
}}
/>
);
export const PrivateSourcesTable: React.FC<PrivateSourcesTableProps> = ({
sourceType,
sourceSection: { isEnabled: sectionEnabled, contentSources },
updateSource,
updateEnabled,
}) => {
const { hasPlatinumLicense } = useValues(LicensingLogic);
const { isEnabled } = useValues(SecurityLogic);
const isRemote = sourceType === 'remote';
const hasSources = contentSources.length > 0;
const panelDisabled = !isEnabled || !hasPlatinumLicense;
const sectionDisabled = !sectionEnabled;
const panelClass = classNames('euiPanel--outline euiPanel--noShadow', {
'euiPanel--disabled': panelDisabled,
});
const tableClass = classNames({ 'euiTable--disabled': sectionDisabled });
const emptyState = (
<>
<EuiSpacer />
<EuiPanel className="euiPanel--inset euiPanel--noShadow euiPanel--outline">
<EuiText textAlign="center" color="subdued" size="s">
<strong>
{isRemote ? REMOTE_SOURCES_EMPTY_TABLE_TITLE : STANDARD_SOURCES_EMPTY_TABLE_TITLE}
</strong>
</EuiText>
<EuiText textAlign="center" color="subdued" size="s">
{isRemote
? REMOTE_SOURCES_EMPTY_TABLE_DESCRIPTION
: STANDARD_SOURCES_EMPTY_TABLE_DESCRIPTION}
</EuiText>
</EuiPanel>
</>
);
const sectionHeading = (
<EuiFlexGroup alignItems="flexStart" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiSpacer size="xs" />
<EuiSwitch
checked={sectionEnabled}
onChange={(e) => updateEnabled(e.target.checked)}
disabled={!isEnabled || !hasPlatinumLicense}
showLabel={false}
label={`${sourceType} Sources Toggle`}
data-test-subj={`${sourceType}EnabledToggle`}
compressed
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h4>{isRemote ? REMOTE_SOURCES_TOGGLE_TEXT : STANDARD_SOURCES_TOGGLE_TEXT}</h4>
</EuiText>
<EuiText color="subdued" size="s">
{isRemote ? REMOTE_SOURCES_TABLE_DESCRIPTION : STANDARD_SOURCES_TABLE_DESCRIPTION}
</EuiText>
{!hasSources && emptyState}
</EuiFlexItem>
</EuiFlexGroup>
);
const sourcesTable = (
<>
<EuiSpacer />
<EuiTable className={tableClass}>
<EuiTableHeader>
<EuiTableHeaderCell>{SOURCE}</EuiTableHeaderCell>
<EuiTableHeaderCell />
</EuiTableHeader>
<EuiTableBody>
{contentSources.map((source, i) => (
<EuiTableRow key={i}>
<EuiTableRowCell>{source.name}</EuiTableRowCell>
<EuiTableRowCell>
<EuiSwitch
checked={!!source.isEnabled}
disabled={sectionDisabled}
onChange={(e) => updateSource(source.id, e.target.checked)}
showLabel={false}
label={`${source.name} Toggle`}
data-test-subj={`${sourceType}SourceToggle`}
compressed
/>
</EuiTableRowCell>
</EuiTableRow>
))}
</EuiTableBody>
</EuiTable>
</>
);
return (
<EuiPanel className={panelClass}>
{sectionHeading}
{hasSources && sourcesTable}
</EuiPanel>
);
};

View file

@ -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 { Security } from './security';

View file

@ -0,0 +1,112 @@
/*
* 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 { setMockValues, setMockActions } from '../../../__mocks__';
import { unmountHandler } from '../../../__mocks__/shallow_useeffect.mock';
import React from 'react';
import { shallow } from 'enzyme';
import { EuiSwitch, EuiConfirmModal } from '@elastic/eui';
import { Loading } from '../../../shared/loading';
import { ViewContentHeader } from '../../components/shared/view_content_header';
import { Security } from './security';
describe('Security', () => {
const initializeSourceRestrictions = jest.fn();
const updatePrivateSourcesEnabled = jest.fn();
const updateRemoteEnabled = jest.fn();
const updateRemoteSource = jest.fn();
const updateStandardEnabled = jest.fn();
const updateStandardSource = jest.fn();
const saveSourceRestrictions = jest.fn();
const resetState = jest.fn();
const mockValues = {
isEnabled: true,
remote: { isEnabled: true, contentSources: [] },
standard: { isEnabled: true, contentSources: [] },
dataLoading: false,
unsavedChanges: false,
hasPlatinumLicense: true,
};
beforeEach(() => {
setMockValues(mockValues);
setMockActions({
initializeSourceRestrictions,
updatePrivateSourcesEnabled,
updateRemoteEnabled,
updateRemoteSource,
updateStandardEnabled,
updateStandardSource,
saveSourceRestrictions,
resetState,
});
});
it('renders on Basic license', () => {
setMockValues({ ...mockValues, hasPlatinumLicense: false });
const wrapper = shallow(<Security />);
expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(true);
});
it('renders on Platinum license', () => {
const wrapper = shallow(<Security />);
expect(wrapper.find(ViewContentHeader)).toHaveLength(1);
expect(wrapper.find(EuiSwitch).prop('disabled')).toEqual(false);
});
it('returns Loading when loading', () => {
setMockValues({ ...mockValues, dataLoading: true });
const wrapper = shallow(<Security />);
expect(wrapper.find(Loading)).toHaveLength(1);
});
it('handles window.onbeforeunload change', () => {
setMockValues({ ...mockValues, unsavedChanges: true });
shallow(<Security />);
expect(window.onbeforeunload!({} as any)).toEqual(
'Your private sources settings have not been saved. Are you sure you want to leave?'
);
});
it('handles window.onbeforeunload unmount', () => {
setMockValues({ ...mockValues, unsavedChanges: true });
shallow(<Security />);
unmountHandler();
expect(window.onbeforeunload).toEqual(null);
});
it('handles switch click', () => {
const wrapper = shallow(<Security />);
const privateSourcesSwitch = wrapper.find(EuiSwitch);
const event = { target: { checked: true } };
privateSourcesSwitch.prop('onChange')(event as any);
expect(updatePrivateSourcesEnabled).toHaveBeenCalled();
});
it('handles confirmModal submission', () => {
setMockValues({ ...mockValues, unsavedChanges: true });
const wrapper = shallow(<Security />);
const header = wrapper.find(ViewContentHeader).dive();
header.find('[data-test-subj="SaveSettingsButton"]').prop('onClick')!({} as any);
const modal = wrapper.find(EuiConfirmModal);
modal.prop('onConfirm')!({} as any);
expect(saveSourceRestrictions).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,196 @@
/*
* 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 classNames from 'classnames';
import { useActions, useValues } from 'kea';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiText,
EuiSpacer,
EuiPanel,
EuiConfirmModal,
EuiOverlayMask,
} from '@elastic/eui';
import { LicensingLogic } from '../../../shared/licensing';
import { FlashMessages } from '../../../shared/flash_messages';
import { LicenseCallout } from '../../components/shared/license_callout';
import { Loading } from '../../../shared/loading';
import { ViewContentHeader } from '../../components/shared/view_content_header';
import { SecurityLogic } from './security_logic';
import { PrivateSourcesTable } from './components/private_sources_table';
import {
SECURITY_UNSAVED_CHANGES_MESSAGE,
RESET_BUTTON,
SAVE_SETTINGS_BUTTON,
SAVE_CHANGES_BUTTON,
KEEP_EDITING_BUTTON,
PRIVATE_SOURCES,
PRIVATE_SOURCES_DESCRIPTION,
PRIVATE_SOURCES_TOGGLE_DESCRIPTION,
PRIVATE_PLATINUM_LICENSE_CALLOUT,
CONFIRM_CHANGES_TEXT,
PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT,
} from '../../constants';
export const Security: React.FC = () => {
const [confirmModalVisible, setConfirmModalVisibility] = useState(false);
const hideConfirmModal = () => setConfirmModalVisibility(false);
const showConfirmModal = () => setConfirmModalVisibility(true);
const { hasPlatinumLicense } = useValues(LicensingLogic);
const {
initializeSourceRestrictions,
updatePrivateSourcesEnabled,
updateRemoteEnabled,
updateRemoteSource,
updateStandardEnabled,
updateStandardSource,
saveSourceRestrictions,
resetState,
} = useActions(SecurityLogic);
const { isEnabled, remote, standard, dataLoading, unsavedChanges } = useValues(SecurityLogic);
useEffect(() => {
initializeSourceRestrictions();
}, []);
useEffect(() => {
window.onbeforeunload = unsavedChanges ? () => SECURITY_UNSAVED_CHANGES_MESSAGE : null;
return () => {
window.onbeforeunload = null;
};
}, [unsavedChanges]);
if (dataLoading) return <Loading />;
const panelClass = classNames('euiPanel--noShadow', {
'euiPanel--disabled': !hasPlatinumLicense,
});
const savePrivateSources = () => {
saveSourceRestrictions();
hideConfirmModal();
};
const headerActions = (
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiButtonEmpty disabled={!unsavedChanges} onClick={resetState}>
{RESET_BUTTON}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
disabled={!hasPlatinumLicense || !unsavedChanges}
onClick={showConfirmModal}
fill
data-test-subj="SaveSettingsButton"
>
{SAVE_SETTINGS_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
const header = (
<>
<ViewContentHeader
title={PRIVATE_SOURCES}
alignItems="flexStart"
description={PRIVATE_SOURCES_DESCRIPTION}
action={headerActions}
/>
<EuiSpacer />
</>
);
const allSourcesToggle = (
<EuiPanel paddingSize="none" className={panelClass}>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiSwitch
checked={isEnabled}
onChange={(e) => updatePrivateSourcesEnabled(e.target.checked)}
disabled={!hasPlatinumLicense}
showLabel={false}
label="Private Sources Toggle"
data-test-subj="PrivateSourcesToggle"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<h4>{PRIVATE_SOURCES_TOGGLE_DESCRIPTION}</h4>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
const platinumLicenseCallout = (
<>
<EuiSpacer size="s" />
<LicenseCallout message={PRIVATE_PLATINUM_LICENSE_CALLOUT} />
</>
);
const sourceTables = (
<>
<EuiSpacer size="xl" />
<PrivateSourcesTable
sourceType="remote"
sourceSection={remote}
updateEnabled={updateRemoteEnabled}
updateSource={updateRemoteSource}
/>
<EuiSpacer size="xxl" />
<PrivateSourcesTable
sourceType="standard"
sourceSection={standard}
updateEnabled={updateStandardEnabled}
updateSource={updateStandardSource}
/>
</>
);
const confirmModal = (
<EuiOverlayMask>
<EuiConfirmModal
title={CONFIRM_CHANGES_TEXT}
onConfirm={savePrivateSources}
onCancel={hideConfirmModal}
buttonColor="primary"
cancelButtonText={KEEP_EDITING_BUTTON}
confirmButtonText={SAVE_CHANGES_BUTTON}
>
{PRIVATE_SOURCES_UPDATE_CONFIRMATION_TEXT}
</EuiConfirmModal>
</EuiOverlayMask>
);
return (
<>
<FlashMessages />
{header}
{allSourcesToggle}
{!hasPlatinumLicense && platinumLicenseCallout}
{sourceTables}
{confirmModalVisible && confirmModal}
</>
);
};

View file

@ -0,0 +1,169 @@
/*
* 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 { mockHttpValues, mockFlashMessageHelpers } from '../../../__mocks__';
import { SecurityLogic } from './security_logic';
import { nextTick } from '@kbn/test/jest';
describe('SecurityLogic', () => {
const { http } = mockHttpValues;
const { flashAPIErrors } = mockFlashMessageHelpers;
const { mount } = new LogicMounter(SecurityLogic);
beforeEach(() => {
jest.clearAllMocks();
mount();
});
const defaultValues = {
dataLoading: true,
cachedServerState: {},
isEnabled: false,
remote: {},
standard: {},
unsavedChanges: true,
};
const serverProps = {
isEnabled: true,
remote: {
isEnabled: true,
contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }],
},
standard: {
isEnabled: true,
contentSources: [{ id: 'one_drive', name: 'OneDrive', isEnabled: true }],
},
};
it('has expected default values', () => {
expect(SecurityLogic.values).toEqual(defaultValues);
});
describe('actions', () => {
it('setServerProps', () => {
SecurityLogic.actions.setServerProps(serverProps);
expect(SecurityLogic.values.isEnabled).toEqual(true);
});
it('setSourceRestrictionsUpdated', () => {
SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps);
expect(SecurityLogic.values.isEnabled).toEqual(true);
});
it('updatePrivateSourcesEnabled', () => {
SecurityLogic.actions.updatePrivateSourcesEnabled(false);
expect(SecurityLogic.values.isEnabled).toEqual(false);
});
it('updateRemoteEnabled', () => {
SecurityLogic.actions.updateRemoteEnabled(false);
expect(SecurityLogic.values.remote.isEnabled).toEqual(false);
});
it('updateStandardEnabled', () => {
SecurityLogic.actions.updateStandardEnabled(false);
expect(SecurityLogic.values.standard.isEnabled).toEqual(false);
});
it('updateRemoteSource', () => {
SecurityLogic.actions.setServerProps(serverProps);
SecurityLogic.actions.updateRemoteSource('gmail', false);
expect(SecurityLogic.values.remote.contentSources[0].isEnabled).toEqual(false);
});
it('updateStandardSource', () => {
SecurityLogic.actions.setServerProps(serverProps);
SecurityLogic.actions.updateStandardSource('one_drive', false);
expect(SecurityLogic.values.standard.contentSources[0].isEnabled).toEqual(false);
});
});
describe('selectors', () => {
describe('unsavedChanges', () => {
it('returns true while loading', () => {
expect(SecurityLogic.values.unsavedChanges).toEqual(true);
});
it('returns false after loading', () => {
SecurityLogic.actions.setServerProps(serverProps);
expect(SecurityLogic.values.unsavedChanges).toEqual(false);
});
});
});
describe('listeners', () => {
describe('initializeSourceRestrictions', () => {
it('calls API and sets values', async () => {
const setServerPropsSpy = jest.spyOn(SecurityLogic.actions, 'setServerProps');
http.get.mockReturnValue(Promise.resolve(serverProps));
SecurityLogic.actions.initializeSourceRestrictions();
expect(http.get).toHaveBeenCalledWith(
'/api/workplace_search/org/security/source_restrictions'
);
await nextTick();
expect(setServerPropsSpy).toHaveBeenCalledWith(serverProps);
});
it('handles error', async () => {
http.get.mockReturnValue(Promise.reject('this is an error'));
SecurityLogic.actions.initializeSourceRestrictions();
try {
await nextTick();
} catch {
expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
}
});
});
describe('saveSourceRestrictions', () => {
it('calls API and sets values', async () => {
http.patch.mockReturnValue(Promise.resolve(serverProps));
SecurityLogic.actions.setSourceRestrictionsUpdated(serverProps);
SecurityLogic.actions.saveSourceRestrictions();
expect(http.patch).toHaveBeenCalledWith(
'/api/workplace_search/org/security/source_restrictions',
{
body: JSON.stringify(serverProps),
}
);
});
it('handles error', async () => {
http.patch.mockReturnValue(Promise.reject('this is an error'));
SecurityLogic.actions.saveSourceRestrictions();
try {
await nextTick();
} catch {
expect(flashAPIErrors).toHaveBeenCalledWith('this is an error');
}
});
});
describe('resetState', () => {
it('calls API and sets values', async () => {
SecurityLogic.actions.setServerProps(serverProps);
SecurityLogic.actions.updatePrivateSourcesEnabled(false);
SecurityLogic.actions.resetState();
expect(SecurityLogic.values.isEnabled).toEqual(true);
});
});
});
});

View file

@ -0,0 +1,181 @@
/*
* 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 { cloneDeep } from 'lodash';
import { isEqual } from 'lodash';
import { kea, MakeLogicType } from 'kea';
import {
clearFlashMessages,
setSuccessMessage,
flashAPIErrors,
} from '../../../shared/flash_messages';
import { HttpLogic } from '../../../shared/http';
import { AppLogic } from '../../app_logic';
import { SOURCE_RESTRICTIONS_SUCCESS_MESSAGE } from '../../constants';
export interface PrivateSource {
id: string;
name: string;
isEnabled: boolean;
}
export interface PrivateSourceSection {
isEnabled: boolean;
contentSources: PrivateSource[];
}
export interface SecurityServerProps {
isEnabled: boolean;
remote: PrivateSourceSection;
standard: PrivateSourceSection;
}
interface SecurityValues extends SecurityServerProps {
dataLoading: boolean;
unsavedChanges: boolean;
cachedServerState: SecurityServerProps;
}
interface SecurityActions {
setServerProps(serverProps: SecurityServerProps): SecurityServerProps;
setSourceRestrictionsUpdated(serverProps: SecurityServerProps): SecurityServerProps;
initializeSourceRestrictions(): void;
saveSourceRestrictions(): void;
updatePrivateSourcesEnabled(isEnabled: boolean): { isEnabled: boolean };
updateRemoteEnabled(isEnabled: boolean): { isEnabled: boolean };
updateRemoteSource(
sourceId: string,
isEnabled: boolean
): { sourceId: string; isEnabled: boolean };
updateStandardEnabled(isEnabled: boolean): { isEnabled: boolean };
updateStandardSource(
sourceId: string,
isEnabled: boolean
): { sourceId: string; isEnabled: boolean };
resetState(): void;
}
const route = '/api/workplace_search/org/security/source_restrictions';
export const SecurityLogic = kea<MakeLogicType<SecurityValues, SecurityActions>>({
path: ['enterprise_search', 'workplace_search', 'security_logic'],
actions: {
setServerProps: (serverProps: SecurityServerProps) => serverProps,
setSourceRestrictionsUpdated: (serverProps: SecurityServerProps) => serverProps,
initializeSourceRestrictions: () => true,
saveSourceRestrictions: () => null,
updatePrivateSourcesEnabled: (isEnabled: boolean) => ({ isEnabled }),
updateRemoteEnabled: (isEnabled: boolean) => ({ isEnabled }),
updateRemoteSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }),
updateStandardEnabled: (isEnabled: boolean) => ({ isEnabled }),
updateStandardSource: (sourceId: string, isEnabled: boolean) => ({ sourceId, isEnabled }),
resetState: () => null,
},
reducers: {
dataLoading: [
true,
{
setServerProps: () => false,
},
],
cachedServerState: [
{} as SecurityServerProps,
{
setServerProps: (_, serverProps) => cloneDeep(serverProps),
setSourceRestrictionsUpdated: (_, serverProps) => cloneDeep(serverProps),
},
],
isEnabled: [
false,
{
setServerProps: (_, { isEnabled }) => isEnabled,
setSourceRestrictionsUpdated: (_, { isEnabled }) => isEnabled,
updatePrivateSourcesEnabled: (_, { isEnabled }) => isEnabled,
},
],
remote: [
{} as PrivateSourceSection,
{
setServerProps: (_, { remote }) => remote,
setSourceRestrictionsUpdated: (_, { remote }) => remote,
updateRemoteEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }),
updateRemoteSource: (state, { sourceId, isEnabled }) =>
updateSourceEnabled(state, sourceId, isEnabled),
},
],
standard: [
{} as PrivateSourceSection,
{
setServerProps: (_, { standard }) => standard,
setSourceRestrictionsUpdated: (_, { standard }) => standard,
updateStandardEnabled: (state, { isEnabled }) => ({ ...state, isEnabled }),
updateStandardSource: (state, { sourceId, isEnabled }) =>
updateSourceEnabled(state, sourceId, isEnabled),
},
],
},
selectors: ({ selectors }) => ({
unsavedChanges: [
() => [
selectors.cachedServerState,
selectors.isEnabled,
selectors.remote,
selectors.standard,
],
(cached, isEnabled, remote, standard) =>
cached.isEnabled !== isEnabled ||
!isEqual(cached.remote, remote) ||
!isEqual(cached.standard, standard),
],
}),
listeners: ({ actions, values }) => ({
initializeSourceRestrictions: async () => {
const { http } = HttpLogic.values;
try {
const response = await http.get(route);
actions.setServerProps(response);
} catch (e) {
flashAPIErrors(e);
}
},
saveSourceRestrictions: async () => {
const { isEnabled, remote, standard } = values;
const serverData = { isEnabled, remote, standard };
const body = JSON.stringify(serverData);
const { http } = HttpLogic.values;
try {
const response = await http.patch(route, { body });
actions.setSourceRestrictionsUpdated(response);
setSuccessMessage(SOURCE_RESTRICTIONS_SUCCESS_MESSAGE);
AppLogic.actions.setSourceRestriction(isEnabled);
} catch (e) {
flashAPIErrors(e);
}
},
resetState: () => {
actions.setServerProps(cloneDeep(values.cachedServerState));
clearFlashMessages();
},
}),
});
const updateSourceEnabled = (
section: PrivateSourceSection,
id: string,
isEnabled: boolean
): PrivateSourceSection => {
const updatedSection = { ...section };
const sources = updatedSection.contentSources;
const sourceIndex = sources.findIndex((source) => source.id === id);
updatedSection.contentSources[sourceIndex] = { ...sources[sourceIndex], isEnabled };
return updatedSection;
};

View file

@ -10,10 +10,12 @@ import { registerOverviewRoute } from './overview';
import { registerGroupsRoutes } from './groups';
import { registerSourcesRoutes } from './sources';
import { registerSettingsRoutes } from './settings';
import { registerSecurityRoutes } from './security';
export const registerWorkplaceSearchRoutes = (dependencies: RouteDependencies) => {
registerOverviewRoute(dependencies);
registerGroupsRoutes(dependencies);
registerSourcesRoutes(dependencies);
registerSettingsRoutes(dependencies);
registerSecurityRoutes(dependencies);
};

View file

@ -0,0 +1,108 @@
/*
* 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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__';
import { registerSecurityRoute, registerSecuritySourceRestrictionsRoute } from './security';
describe('security routes', () => {
describe('GET /api/workplace_search/org/security', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/workplace_search/org/security',
});
registerSecurityRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
mockRouter.callRoute({});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/security',
});
});
});
describe('GET /api/workplace_search/org/security/source_restrictions', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'get',
path: '/api/workplace_search/org/security/source_restrictions',
payload: 'body',
});
registerSecuritySourceRestrictionsRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
mockRouter.callRoute({});
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/security/source_restrictions',
});
});
});
describe('PATCH /api/workplace_search/org/security/source_restrictions', () => {
let mockRouter: MockRouter;
beforeEach(() => {
jest.clearAllMocks();
mockRouter = new MockRouter({
method: 'patch',
path: '/api/workplace_search/org/security/source_restrictions',
payload: 'body',
});
registerSecuritySourceRestrictionsRoute({
...mockDependencies,
router: mockRouter.router,
});
});
it('creates a request handler', () => {
expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({
path: '/ws/org/security/source_restrictions',
});
});
describe('validates', () => {
it('correctly', () => {
const request = {
body: {
isEnabled: true,
remote: {
isEnabled: true,
contentSources: [{ id: 'gmail', name: 'Gmail', isEnabled: true }],
},
standard: {
isEnabled: false,
contentSources: [{ id: 'dropbox', name: 'Dropbox', isEnabled: false }],
},
},
};
mockRouter.shouldValidate(request);
});
});
});
});

View file

@ -0,0 +1,78 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { RouteDependencies } from '../../plugin';
export function registerSecurityRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/workplace_search/org/security',
validate: false,
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/security',
})
);
}
export function registerSecuritySourceRestrictionsRoute({
router,
enterpriseSearchRequestHandler,
}: RouteDependencies) {
router.get(
{
path: '/api/workplace_search/org/security/source_restrictions',
validate: false,
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/security/source_restrictions',
})
);
router.patch(
{
path: '/api/workplace_search/org/security/source_restrictions',
validate: {
body: schema.object({
isEnabled: schema.boolean(),
remote: schema.object({
isEnabled: schema.boolean(),
contentSources: schema.arrayOf(
schema.object({
isEnabled: schema.boolean(),
id: schema.string(),
name: schema.string(),
})
),
}),
standard: schema.object({
isEnabled: schema.boolean(),
contentSources: schema.arrayOf(
schema.object({
isEnabled: schema.boolean(),
id: schema.string(),
name: schema.string(),
})
),
}),
}),
},
},
enterpriseSearchRequestHandler.createRequest({
path: '/ws/org/security/source_restrictions',
})
);
}
export const registerSecurityRoutes = (dependencies: RouteDependencies) => {
registerSecurityRoute(dependencies);
registerSecuritySourceRestrictionsRoute(dependencies);
};