diff --git a/src/server/saved_objects/client/saved_objects_client.js b/src/server/saved_objects/client/saved_objects_client.js
index 79dc55540e039..c1f4b65692718 100644
--- a/src/server/saved_objects/client/saved_objects_client.js
+++ b/src/server/saved_objects/client/saved_objects_client.js
@@ -16,7 +16,7 @@ export class SavedObjectsClient {
index,
mappings,
callCluster,
- onBeforeWrite = () => {},
+ onBeforeWrite = () => { },
} = options;
this._index = index;
diff --git a/x-pack/plugins/spaces/common/__snapshots__/spaces_url_parser.test.js.snap b/x-pack/plugins/spaces/common/__snapshots__/spaces_url_parser.test.js.snap
new file mode 100644
index 0000000000000..a42d029097b67
--- /dev/null
+++ b/x-pack/plugins/spaces/common/__snapshots__/spaces_url_parser.test.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`addSpaceUrlContext it throws an error when the requested path does not start with a slash 1`] = `"path must start with a /"`;
diff --git a/x-pack/plugins/spaces/common/constants.js b/x-pack/plugins/spaces/common/constants.js
index 40e064ef98359..ca67766a9adb6 100644
--- a/x-pack/plugins/spaces/common/constants.js
+++ b/x-pack/plugins/spaces/common/constants.js
@@ -5,3 +5,10 @@
*/
export const DEFAULT_SPACE_ID = `default`;
+
+export const SELECTED_SPACE_COOKIE = 'selectedSpace';
+
+/**
+ * Cookie expiration for the user's selected Space. (90 days)
+ */
+export const SELECTED_SPACE_COOKIE_TTL_MILLIS = 1000 * 60 * 60 * 24 * 90;
diff --git a/x-pack/plugins/spaces/common/index.js b/x-pack/plugins/spaces/common/index.js
new file mode 100644
index 0000000000000..0adba302d4681
--- /dev/null
+++ b/x-pack/plugins/spaces/common/index.js
@@ -0,0 +1,17 @@
+/*
+ * 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 {
+ addSpaceUrlContext,
+ stripSpaceUrlContext,
+ getSpaceUrlContext,
+} from './spaces_url_parser';
+
+export {
+ DEFAULT_SPACE_ID,
+ SELECTED_SPACE_COOKIE,
+ SELECTED_SPACE_COOKIE_TTL_MILLIS,
+} from './constants';
diff --git a/x-pack/plugins/spaces/common/spaces_url_parser.js b/x-pack/plugins/spaces/common/spaces_url_parser.js
index 075f09eea03a3..e4c41526997e2 100644
--- a/x-pack/plugins/spaces/common/spaces_url_parser.js
+++ b/x-pack/plugins/spaces/common/spaces_url_parser.js
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-export function getSpaceUrlContext(basePath = '/', defaultContext = null) {
+export function getSpaceUrlContext(basePath = '/', defaultContext = '') {
// Look for `/s/space-url-context` in the base path
const matchResult = basePath.match(/\/s\/([a-z0-9\-]+)/);
@@ -42,3 +42,14 @@ export function stripSpaceUrlContext(basePath = '/') {
return basePathWithoutSpace;
}
+
+export function addSpaceUrlContext(basePath = '/', urlContext = '', requestedPath = '') {
+ if (requestedPath && !requestedPath.startsWith('/')) {
+ throw new Error(`path must start with a /`);
+ }
+
+ if (urlContext) {
+ return `${basePath}/s/${urlContext}${requestedPath}`;
+ }
+ return `${basePath}${requestedPath}`;
+}
diff --git a/x-pack/plugins/spaces/common/spaces_url_parser.test.js b/x-pack/plugins/spaces/common/spaces_url_parser.test.js
index ee0813b2f70ac..33a5bf1f8bd70 100644
--- a/x-pack/plugins/spaces/common/spaces_url_parser.test.js
+++ b/x-pack/plugins/spaces/common/spaces_url_parser.test.js
@@ -3,37 +3,61 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
-import { stripSpaceUrlContext, getSpaceUrlContext } from './spaces_url_parser';
+import { stripSpaceUrlContext, getSpaceUrlContext, addSpaceUrlContext } from './spaces_url_parser';
-test('it removes the space url context from the base path when the space is not at the root', () => {
- const basePath = `/foo/s/my-space`;
- expect(stripSpaceUrlContext(basePath)).toEqual('/foo');
-});
+describe('stripSpaceUrlContext', () => {
+ test('it removes the space url context from the base path when the space is not at the root', () => {
+ const basePath = `/foo/s/my-space`;
+ expect(stripSpaceUrlContext(basePath)).toEqual('/foo');
+ });
-test('it removes the space url context from the base path when the space is the root', () => {
- const basePath = `/s/my-space`;
- expect(stripSpaceUrlContext(basePath)).toEqual('');
-});
+ test('it removes the space url context from the base path when the space is the root', () => {
+ const basePath = `/s/my-space`;
+ expect(stripSpaceUrlContext(basePath)).toEqual('');
+ });
-test(`it doesn't change base paths without a space url context`, () => {
- const basePath = `/this/is/a-base-path/ok`;
- expect(stripSpaceUrlContext(basePath)).toEqual(basePath);
-});
+ test(`it doesn't change base paths without a space url context`, () => {
+ const basePath = `/this/is/a-base-path/ok`;
+ expect(stripSpaceUrlContext(basePath)).toEqual(basePath);
+ });
-test('it accepts no parameters', () => {
- expect(stripSpaceUrlContext()).toEqual('');
-});
+ test('it accepts no parameters', () => {
+ expect(stripSpaceUrlContext()).toEqual('');
+ });
-test('it remove the trailing slash', () => {
- expect(stripSpaceUrlContext('/')).toEqual('');
+ test('it remove the trailing slash', () => {
+ expect(stripSpaceUrlContext('/')).toEqual('');
+ });
});
-test('it identifies the space url context', () => {
- const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`;
- expect(getSpaceUrlContext(basePath)).toEqual('my-awesome-space-lives-here');
+describe('getSpaceUrlContext', () => {
+ test('it identifies the space url context', () => {
+ const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`;
+ expect(getSpaceUrlContext(basePath)).toEqual('my-awesome-space-lives-here');
+ });
+
+ test('it handles base url without a space url context', () => {
+ const basePath = `/this/is/a/crazy/path/s`;
+ expect(getSpaceUrlContext(basePath)).toEqual('');
+ });
});
-test('it handles base url without a space url context', () => {
- const basePath = `/this/is/a/crazy/path/s`;
- expect(getSpaceUrlContext(basePath)).toEqual(null);
+describe('addSpaceUrlContext', () => {
+ test('handles no parameters', () => {
+ expect(addSpaceUrlContext()).toEqual(`/`);
+ });
+
+ test('it adds to the basePath correctly', () => {
+ expect(addSpaceUrlContext('/my/base/path', 'url-context')).toEqual('/my/base/path/s/url-context');
+ });
+
+ test('it appends the requested path to the end of the url context', () => {
+ expect(addSpaceUrlContext('/base', 'context', '/final/destination')).toEqual('/base/s/context/final/destination');
+ });
+
+ test('it throws an error when the requested path does not start with a slash', () => {
+ expect(() => {
+ addSpaceUrlContext('', '', 'foo');
+ }).toThrowErrorMatchingSnapshot();
+ });
});
diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js
index 603b8ddb22e11..dd37e0a40d74e 100644
--- a/x-pack/plugins/spaces/index.js
+++ b/x-pack/plugins/spaces/index.js
@@ -14,6 +14,7 @@ import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status';
import { getActiveSpace } from './server/lib/get_active_space';
import { wrapError } from './server/lib/errors';
import mappings from './mappings.json';
+import { initSelectedSpaceState } from './server/lib/selected_space_state';
export const spaces = (kibana) => new kibana.Plugin({
id: 'spaces',
@@ -24,6 +25,7 @@ export const spaces = (kibana) => new kibana.Plugin({
config(Joi) {
return Joi.object({
enabled: Joi.boolean().default(true),
+ rememberSelectedSpace: Joi.boolean().default(true),
}).default();
},
@@ -47,12 +49,19 @@ export const spaces = (kibana) => new kibana.Plugin({
};
},
replaceInjectedVars: async function (vars, request) {
+ // A rather obtuse way of preventing the Kibana login/logout resources from trying to make these requests.
+ // This seems safer than excluding a couple of hard-coded paths.
+ const canReplace = request.path.startsWith('/app/');
+ if (!canReplace) {
+ return vars;
+ }
+
try {
vars.activeSpace = {
valid: true,
space: await getActiveSpace(request.getSavedObjectsClient(), request.getBasePath())
};
- } catch(e) {
+ } catch (e) {
vars.activeSpace = {
valid: false,
error: wrapError(e).output.payload
@@ -76,6 +85,8 @@ export const spaces = (kibana) => new kibana.Plugin({
initSpacesApi(server);
+ initSelectedSpaceState(server, config);
+
initSpacesRequestInterceptors(server);
await createDefaultSpace(server);
diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.js b/x-pack/plugins/spaces/public/lib/spaces_manager.js
index 77b1ea9b514ca..d7db211c28a3f 100644
--- a/x-pack/plugins/spaces/public/lib/spaces_manager.js
+++ b/x-pack/plugins/spaces/public/lib/spaces_manager.js
@@ -35,4 +35,14 @@ export class SpacesManager {
return await this._httpAgent
.delete(`${this._baseUrl}/space/${space.id}`);
}
+
+ async changeSelectedSpace(space) {
+ return await this._httpAgent
+ .put(`${this._baseUrl}/space/${space.id}/select`)
+ .then(response => {
+ if (response.data && response.data.location) {
+ window.location = response.data.location;
+ }
+ });
+ }
}
diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.js b/x-pack/plugins/spaces/public/views/components/space_cards.js
index 2e92ce4a290ea..508c8ea53342d 100644
--- a/x-pack/plugins/spaces/public/views/components/space_cards.js
+++ b/x-pack/plugins/spaces/public/views/components/space_cards.js
@@ -8,7 +8,7 @@ import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import chrome from 'ui/chrome';
import { SpaceCard } from './space_card';
-import { stripSpaceUrlContext } from '../../../common/spaces_url_parser';
+import { stripSpaceUrlContext, addSpaceUrlContext } from '../../../common/spaces_url_parser';
import {
EuiFlexGroup,
EuiFlexItem,
@@ -48,7 +48,7 @@ export class SpaceCards extends Component {
return () => {
const baseUrlWithoutSpace = stripSpaceUrlContext(chrome.getBasePath());
- window.location = `${baseUrlWithoutSpace}/s/${space.urlContext}`;
+ window.location = addSpaceUrlContext(baseUrlWithoutSpace, space.urlContext);
};
};
}
diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_context_menu.test.js.snap b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_context_menu.test.js.snap
new file mode 100644
index 0000000000000..283ee03f5682f
--- /dev/null
+++ b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_context_menu.test.js.snap
@@ -0,0 +1,198 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`it renders the provided spaces 1`] = `
+
+
+
+
+
+
+
+
+ Select a Space
+
+
+
+
+
+
+
+
+ a space
+ ,
+
+ b space
+ ,
+
+ Default Space
+ ,
+ ]
+ }
+ watchedItemProps={
+ Array [
+ "data-id",
+ ]
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.js b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.js
new file mode 100644
index 0000000000000..603d0a00e8b2b
--- /dev/null
+++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.js
@@ -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, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+ EuiContextMenuPanel,
+ EuiContextMenuItem,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiButton,
+ EuiText,
+} from '@elastic/eui';
+
+export class SpacesContextMenu extends Component {
+ static propTypes = {
+ spaces: PropTypes.array.isRequired,
+ onSelectSpace: PropTypes.func.isRequired,
+ showManageButton: PropTypes.bool
+ }
+
+ render() {
+ const items = this.props.spaces.map(this.buildSpaceMenuOption);
+
+ return (
+
+ Select a Space
+
+ {this.buildManageButton()}
+
+ );
+ }
+
+ buildSpaceMenuOption = (space) => {
+ return (
+
+ {space.name}
+
+ );
+ }
+
+ buildManageButton = () => {
+ if (!this.props.showManageButton) {
+ return null;
+ }
+ return (
+
+ { }} size={'s'}>Manage Spaces
+
+ );
+ }
+
+ onSpaceClick = (space) => this.props.onSelectSpace.bind(this, space)
+}
diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.test.js b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.test.js
new file mode 100644
index 0000000000000..65408c01a1b01
--- /dev/null
+++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_context_menu.test.js
@@ -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 React from 'react';
+import { shallow, mount } from 'enzyme';
+import { SpacesContextMenu } from './spaces_context_menu';
+import { EuiContextMenuItem } from '@elastic/eui';
+
+const spaces = [{
+ id: 'a-space',
+ name: 'a space',
+ urlContext: 'a-space',
+}, {
+ id: 'b-space',
+ name: 'b space',
+ urlContext: 'b-space',
+}, {
+ id: 'default',
+ name: 'Default Space',
+ urlContext: '',
+ _reserved: true
+}];
+
+test('it renders without blowing up', () => {
+ shallow();
+});
+
+test('it renders the provided spaces', () => {
+ const wrapper = mount();
+ expect(wrapper.find(EuiContextMenuItem)).toHaveLength(spaces.length);
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('it calls the click handler when a space is clicked', () => {
+ const clickHandler = jest.fn();
+ const wrapper = mount();
+
+ expect(clickHandler).toHaveBeenCalledTimes(0);
+
+ wrapper.find('button[data-id="default"]').simulate('click');
+
+ expect(clickHandler).toHaveBeenCalledTimes(1);
+ const [calledWithSpace] = clickHandler.mock.calls[0];
+ expect(calledWithSpace).toEqual(spaces[2]);
+});
diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js
index 74be5a7f627dd..0f29cfea606c9 100644
--- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js
+++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js
@@ -13,7 +13,7 @@ import 'plugins/spaces/views/nav_control/nav_control.less';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
-import { NavControlModal } from 'plugins/spaces/views/nav_control/nav_control_modal';
+import { NavControlPopover } from 'plugins/spaces/views/nav_control/nav_control_popover';
chromeNavControlsRegistry.register(constant({
name: 'spaces',
@@ -23,12 +23,12 @@ chromeNavControlsRegistry.register(constant({
const module = uiModules.get('spaces', ['kibana']);
-module.controller('spacesNavController', ($scope, $http, chrome, activeSpace) => {
+module.controller('spacesNavController', ($scope, $http, chrome, globalNavState, activeSpace) => {
const domNode = document.getElementById(`spacesNavReactRoot`);
const spacesManager = new SpacesManager($http, chrome);
- render(, domNode);
+ render(, domNode);
// unmount react on controller destroy
$scope.$on('$destroy', () => {
diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.less b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less
index 19f56c5ea1077..eb8569681c4a6 100644
--- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.less
+++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less
@@ -1,8 +1,23 @@
+@import (reference) "~ui/styles/variables";
+
.global-nav-link__icon .spaceNavGraphic {
margin-top: 0.5em;
}
-.selectSpaceModal {
- min-width: 450px;
- max-width: 1200px;
+.spaceSelectorMenu:last-child {
+ margin-bottom: 5px;
+}
+
+.spaceSelectorMenu__manageSpacesButton {
+ margin: 0 5px;
+}
+
+.spaceSelectorMenu__panel {
+ width: calc(@global-nav-open-width - 10px);
+}
+
+.euiFlexItem.spaceSelectorMenu__title {
+ background-color: #fff;
+ padding: 10px;
+ margin-bottom: 0;
}
diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js
deleted file mode 100644
index 1827d5bf8519d..0000000000000
--- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import React, { Component } from 'react';
-import PropTypes from 'prop-types';
-import {
- EuiModal,
- EuiModalHeader,
- EuiModalHeaderTitle,
- EuiModalBody,
- EuiOverlayMask,
- EuiAvatar,
-} from '@elastic/eui';
-import { SpaceCards } from '../components/space_cards';
-import { Notifier } from 'ui/notify';
-
-export class NavControlModal extends Component {
- state = {
- isOpen: false,
- loading: false,
- spaces: []
- };
-
- notifier = new Notifier(`Spaces`);
-
- async loadSpaces() {
- const {
- spacesManager
- } = this.props;
-
- this.setState({
- loading: true
- });
-
- const spaces = await spacesManager.getSpaces();
- this.setState({
- spaces,
- loading: false
- });
- }
-
- componentDidMount() {
- const {
- activeSpace
- } = this.props;
-
- if (activeSpace && !activeSpace.valid) {
- const { error = {} } = activeSpace;
- if (error.message) {
- this.notifier.error(error.message);
- }
- }
- }
-
- render() {
- let modal;
- if (this.state.isOpen) {
- modal = (
-
-
-
- Select a space
-
-
-
-
-
-
- );
- }
-
- return (
-
{this.getActiveSpaceButton()}{modal}
- );
- }
-
- getActiveSpaceButton = () => {
- const {
- activeSpace
- } = this.props;
-
- if (!activeSpace) {
- return null;
- }
-
- if (activeSpace.valid && activeSpace.space) {
- return this.getButton(
- ,
- activeSpace.space.name
- );
- } else if (activeSpace.error) {
- return this.getButton(
- ,
- 'error'
- );
- }
-
- return null;
- };
-
- getButton = (linkIcon, linkTitle) => {
- return (
-
- );
- };
-
- togglePortal = () => {
- const isOpening = !this.state.isOpen;
- if (isOpening) {
- this.loadSpaces();
- }
-
- this.setState({
- isOpen: !this.state.isOpen
- });
- };
-
- closePortal = () => {
- this.setState({
- isOpen: false
- });
- }
-}
-
-NavControlModal.propTypes = {
- activeSpace: PropTypes.object,
- spacesManager: PropTypes.object.isRequired
-};
diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.js
new file mode 100644
index 0000000000000..6e9aed4655731
--- /dev/null
+++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.js
@@ -0,0 +1,137 @@
+/*
+ * 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, { Component } from 'react';
+import PropTypes from 'prop-types';
+import {
+ EuiAvatar,
+ EuiPopover,
+} from '@elastic/eui';
+import { Notifier } from 'ui/notify';
+import { SpacesContextMenu } from './components/spaces_context_menu';
+
+export class NavControlPopover extends Component {
+ state = {
+ isOpen: false,
+ loading: false,
+ spaces: []
+ };
+
+ notifier = new Notifier(`Spaces`);
+
+ async loadSpaces() {
+ const {
+ spacesManager
+ } = this.props;
+
+ this.setState({
+ loading: true
+ });
+
+ const spaces = await spacesManager.getSpaces();
+ this.setState({
+ spaces,
+ loading: false
+ });
+ }
+
+ componentDidMount() {
+ const {
+ activeSpace
+ } = this.props;
+
+ if (activeSpace && !activeSpace.valid) {
+ const { error = {} } = activeSpace;
+ if (error.message) {
+ this.notifier.error(error.message);
+ }
+ }
+ }
+
+ render() {
+ return (
+
+
+
+ );
+ }
+
+ getActiveSpaceButton = () => {
+ const {
+ activeSpace
+ } = this.props;
+
+ if (!activeSpace) {
+ return null;
+ }
+
+ if (activeSpace.valid && activeSpace.space) {
+ return this.getButton(
+ ,
+ activeSpace.space.name
+ );
+ } else if (activeSpace.error) {
+ return this.getButton(
+ ,
+ 'error'
+ );
+ }
+
+ return null;
+ };
+
+ getButton = (linkIcon, linkTitle) => {
+ return (
+
+ );
+ };
+
+ togglePortal = () => {
+ const isOpening = !this.state.isOpen;
+ if (isOpening) {
+ this.loadSpaces();
+ }
+
+ this.setState({
+ isOpen: !this.state.isOpen
+ });
+ };
+
+ closePortal = () => {
+ this.setState({
+ isOpen: false
+ });
+ }
+
+ expandGlobalNav = () => {
+ const { globalNavState } = this.props;
+ if (!globalNavState.isOpen()) {
+ globalNavState.setOpen(true);
+ }
+ }
+
+ onSelectSpace = (space) => {
+ this.props.spacesManager.changeSelectedSpace(space);
+ }
+}
+
+NavControlPopover.propTypes = {
+ activeSpace: PropTypes.object,
+ spacesManager: PropTypes.object.isRequired,
+ globalNavState: PropTypes.object.isRequired,
+};
diff --git a/x-pack/plugins/spaces/server/lib/get_active_space.js b/x-pack/plugins/spaces/server/lib/get_active_space.js
index 7fb7821e75274..69156a696f389 100644
--- a/x-pack/plugins/spaces/server/lib/get_active_space.js
+++ b/x-pack/plugins/spaces/server/lib/get_active_space.js
@@ -7,26 +7,19 @@
import Boom from 'boom';
import { wrapError } from './errors';
import { getSpaceUrlContext } from '../../common/spaces_url_parser';
+import { DEFAULT_SPACE_ID } from '../../common/constants';
export async function getActiveSpace(savedObjectsClient, basePath) {
const spaceContext = getSpaceUrlContext(basePath);
- if (!spaceContext) {
+ if (typeof spaceContext !== 'string') {
return null;
}
let spaces;
try {
- const {
- saved_objects: savedObjects
- } = await savedObjectsClient.find({
- type: 'space',
- search: `"${spaceContext}"`,
- search_fields: ['urlContext'],
- });
-
- spaces = savedObjects || [];
- } catch(e) {
+ spaces = await getSpacesFromContext(savedObjectsClient, spaceContext);
+ } catch (e) {
throw wrapError(e);
}
@@ -49,3 +42,20 @@ export async function getActiveSpace(savedObjectsClient, basePath) {
...spaces[0].attributes
};
}
+
+async function getSpacesFromContext(client, context) {
+ // Workaround for SOC's find operation, which can't "find" on an empty string.
+ if (!context) {
+ return [await client.get('space', DEFAULT_SPACE_ID)];
+ }
+
+ const {
+ saved_objects: savedObjects
+ } = await client.find({
+ type: 'space',
+ search: `"${context}"`,
+ searchFields: ['urlContext'],
+ });
+
+ return savedObjects;
+}
diff --git a/x-pack/plugins/spaces/server/lib/selected_space_state.js b/x-pack/plugins/spaces/server/lib/selected_space_state.js
new file mode 100644
index 0000000000000..97c2b5ee1aa5e
--- /dev/null
+++ b/x-pack/plugins/spaces/server/lib/selected_space_state.js
@@ -0,0 +1,38 @@
+/*
+ * 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 { SELECTED_SPACE_COOKIE, SELECTED_SPACE_COOKIE_TTL_MILLIS } from '../../common';
+
+let rememberSelectedSpace;
+
+export function initSelectedSpaceState(server, config) {
+ rememberSelectedSpace = config.get('xpack.spaces.rememberSelectedSpace');
+ if (!rememberSelectedSpace) {
+ return;
+ }
+
+ server.state(SELECTED_SPACE_COOKIE, {
+ ttl: SELECTED_SPACE_COOKIE_TTL_MILLIS,
+ isHttpOnly: true,
+ isSecure: config.get('server.ssl.enabled'),
+ path: config.get('server.basePath') || null,
+ });
+}
+
+export function setSelectedSpace(request, reply, urlContext) {
+ if (!rememberSelectedSpace) {
+ return;
+ }
+
+ const {
+ [SELECTED_SPACE_COOKIE]: currentSelectedSpace
+ } = request.state;
+
+ // a blank url context is different from undefined. Blank == the Default Space, while undefined means there is no space selected.
+ if (typeof urlContext === 'string' && urlContext !== currentSelectedSpace) {
+ reply.state(SELECTED_SPACE_COOKIE, urlContext);
+ }
+}
diff --git a/x-pack/plugins/spaces/server/lib/selected_space_state.test.js b/x-pack/plugins/spaces/server/lib/selected_space_state.test.js
new file mode 100644
index 0000000000000..60ebc829917ff
--- /dev/null
+++ b/x-pack/plugins/spaces/server/lib/selected_space_state.test.js
@@ -0,0 +1,244 @@
+/*
+ * 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 sinon from 'sinon';
+import { Server } from 'hapi';
+import { initSelectedSpaceState, setSelectedSpace } from './selected_space_state';
+import { SELECTED_SPACE_COOKIE } from '../../common';
+
+function getCookiePartsFromResponse(response) {
+ const { headers } = response;
+ expect(headers).toHaveProperty('set-cookie');
+
+ const cookies = headers['set-cookie'];
+ expect(cookies).toHaveLength(1);
+
+ return cookies[0].split(';').map(a => a.trim());
+}
+
+describe('initSelectedSpaceState', () => {
+ const sandbox = sinon.sandbox.create();
+ const teardowns = [];
+ let request;
+
+ beforeEach(() => {
+ teardowns.push(() => sandbox.restore());
+ request = async (config, setupFn = () => { }) => {
+
+ const server = new Server();
+
+ server.connection({ port: 0 });
+
+ initSelectedSpaceState(server, {
+ get: (key) => {
+ return config[key];
+ }
+ });
+
+ server.route({
+ method: 'GET',
+ path: '/',
+ handler: (req, reply) => {
+ return reply({ path: req.path, url: req.url });
+ }
+ });
+
+ server.ext('onPreResponse', (req, reply) => {
+ reply.state(SELECTED_SPACE_COOKIE, 'foo');
+ return reply.continue();
+ });
+
+ await setupFn(server);
+
+ teardowns.push(() => server.stop());
+
+ const response = await server.inject({
+ method: 'GET',
+ url: '/',
+ });
+
+ if (response && response.isBoom) {
+ throw response;
+ }
+
+ return response;
+ };
+ });
+
+ afterEach(async () => {
+ await Promise.all(teardowns.splice(0).map(fn => fn()));
+ });
+
+ describe('Secure Flag', () => {
+ const values = [true, false];
+ values.forEach(v => {
+ test(`it ${v ? 'sets' : 'does not set'} the Secure flag when server ssl is ${v ? 'enabled' : 'disabled'}`, async () => {
+ const config = {
+ 'server.ssl.enabled': v,
+ 'server.basePath': null,
+ 'xpack.spaces.rememberSelectedSpace': true,
+ };
+
+ const response = await request(config);
+
+ const [nameValuePair, ...rest] = getCookiePartsFromResponse(response);
+
+ expect(nameValuePair).toEqual('selectedSpace=foo');
+ if (v) {
+ expect(rest).toContain('Secure');
+ } else {
+ expect(rest).not.toContain('Secure');
+ }
+ });
+ });
+ });
+
+ test('it sets the httpOnly flag', async () => {
+ const config = {
+ 'server.ssl.enabled': true,
+ 'server.basePath': null,
+ 'xpack.spaces.rememberSelectedSpace': true,
+ };
+
+ const response = await request(config);
+ const attributes = getCookiePartsFromResponse(response);
+ expect(attributes).toContain('HttpOnly');
+ });
+
+ test('it sets the cookie path based on the servers basePath', async () => {
+ const config = {
+ 'server.ssl.enabled': true,
+ 'server.basePath': '/foo/bar',
+ 'xpack.spaces.rememberSelectedSpace': true,
+ };
+
+ const response = await request(config);
+
+ const attributes = getCookiePartsFromResponse(response);
+ expect(attributes).toContain('Path=/foo/bar');
+ });
+});
+
+describe('setSelectedSpace', () => {
+ const sandbox = sinon.sandbox.create();
+ const teardowns = [];
+
+ const serverConfig = {
+ 'server.ssl.enabled': true,
+ 'server.basePath': null,
+ 'xpack.spaces.rememberSelectedSpace': true,
+ };
+
+ let request;
+
+ beforeEach(() => {
+ teardowns.push(() => sandbox.restore());
+ request = async (config, replyHandler = () => { }, reqHeaders = {}) => {
+
+ const server = new Server();
+
+ server.connection({ port: 0 });
+
+ initSelectedSpaceState(server, {
+ get: (key) => {
+ return config[key];
+ }
+ });
+
+ server.route({
+ method: 'GET',
+ path: '/',
+ handler: (req, reply) => {
+ replyHandler(req, reply);
+ return reply({ path: req.path, url: req.url });
+ }
+ });
+
+ teardowns.push(() => server.stop());
+
+ const response = await server.inject({
+ method: 'GET',
+ url: '/',
+ headers: reqHeaders
+ });
+
+ if (response && response.isBoom) {
+ throw response;
+ }
+ return response;
+ };
+ });
+
+ afterEach(async () => {
+ await Promise.all(teardowns.splice(0).map(fn => fn()));
+ });
+
+ test('it sets the selected space cookie', async () => {
+ const response = await request(serverConfig, (req, rep) => {
+ setSelectedSpace(req, rep, 'url-context');
+ });
+
+ const [nameValuePair] = getCookiePartsFromResponse(response);
+ expect(nameValuePair).toEqual('selectedSpace=url-context');
+ });
+
+ test('it does not set the selected space if it is already set to that value', async () => {
+ const requestHeaders = {
+ 'Cookie': 'selectedSpace=url-context'
+ };
+
+ const response = await request(serverConfig, (req, rep) => {
+ setSelectedSpace(req, rep, 'url-context');
+ }, requestHeaders);
+
+ const { headers } = response;
+ expect(headers).not.toHaveProperty('set-cookie');
+ });
+
+ test('it overwrites the selected space if it has changed', async () => {
+ const requestHeaders = {
+ 'Cookie': 'selectedSpace=original-url-context'
+ };
+
+ const response = await request(serverConfig, (req, rep) => {
+ setSelectedSpace(req, rep, 'new-url-context');
+ }, requestHeaders);
+
+ const [nameValuePair] = getCookiePartsFromResponse(response);
+ expect(nameValuePair).toEqual('selectedSpace=new-url-context');
+ });
+
+ test('it sets the selected space even if the url context is empty', async () => {
+ const response = await request(serverConfig, (req, rep) => {
+ setSelectedSpace(req, rep, '');
+ });
+
+ const [nameValuePair] = getCookiePartsFromResponse(response);
+ expect(nameValuePair).toEqual('selectedSpace=');
+ });
+
+ test('it does not set the selected space if the url context is undefined', async () => {
+ const response = await request(serverConfig, (req, rep) => {
+ setSelectedSpace(req, rep);
+ });
+
+ const { headers } = response;
+ expect(headers).not.toHaveProperty('set-cookie');
+ });
+
+ test('it does not set the selected space if the feature is turned off', async () => {
+ const config = {
+ ...serverConfig,
+ 'xpack.spaces.rememberSelectedSpace': false
+ };
+
+ const response = await request(config, (req, rep) => {
+ setSelectedSpace(req, rep, 'url-context');
+ });
+
+ const { headers } = response;
+ expect(headers).not.toHaveProperty('set-cookie');
+ });
+});
diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.js b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js
index 6f4330ba283cc..db5e201980f5e 100644
--- a/x-pack/plugins/spaces/server/lib/space_request_interceptors.js
+++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js
@@ -5,6 +5,8 @@
*/
import { wrapError } from './errors';
+import { addSpaceUrlContext, SELECTED_SPACE_COOKIE } from '../../common';
+import { setSelectedSpace } from './selected_space_state';
export function initSpacesRequestInterceptors(server) {
const contextCache = new WeakMap();
@@ -42,17 +44,23 @@ export function initSpacesRequestInterceptors(server) {
const isRequestingKibanaRoot = path === '/';
const urlContext = contextCache.get(request);
+ const { [SELECTED_SPACE_COOKIE]: selectedSpace } = request.state;
// if requesting the application root, then show the Space Selector UI to allow the user to choose which space
// they wish to visit. This is done "onPostAuth" to allow the Saved Objects Client to use the request's auth scope,
// which is not available at the time of "onRequest".
if (isRequestingKibanaRoot && !urlContext) {
try {
+
const client = request.getSavedObjectsClient();
const { total, saved_objects: spaceObjects } = await client.find({
type: 'space'
});
+ const config = server.config();
+ const basePath = config.get('server.basePath');
+ const defaultRoute = config.get('server.defaultRoute');
+
if (total === 1) {
// If only one space is available, then send user there directly.
// No need for an interstitial screen where there is only one possible outcome.
@@ -61,22 +69,20 @@ export function initSpacesRequestInterceptors(server) {
urlContext
} = space.attributes;
- const config = server.config();
- const basePath = config.get('server.basePath');
- const defaultRoute = config.get('server.defaultRoute');
-
- let destination;
- if (urlContext) {
- destination = `${basePath}/s/${urlContext}${defaultRoute}`;
- } else {
- destination = `${basePath}${defaultRoute}`;
- }
-
+ const destination = addSpaceUrlContext(basePath, urlContext, defaultRoute);
+ setSelectedSpace(request, reply, urlContext);
return reply.redirect(destination);
}
if (total > 0) {
- // render spaces selector instead of home page
+ const preferredSpace = spaceObjects.find(so => so.attributes.urlContext === selectedSpace);
+ // If the user's previously selected space is still available, then send them there automatically
+ if (preferredSpace) {
+ const destination = addSpaceUrlContext(basePath, preferredSpace.attributes.urlContext, defaultRoute);
+ return reply.redirect(destination);
+ }
+
+ // otherwise, render spaces selector instead of home page
const app = server.getHiddenUiAppById('space_selector');
return reply.renderApp(app, {
spaces: spaceObjects.map(so => ({ ...so.attributes, id: so.id }))
@@ -88,8 +94,8 @@ export function initSpacesRequestInterceptors(server) {
}
}
+ setSelectedSpace(request, reply, urlContext);
+
return reply.continue();
});
-
-
}
diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js
index 434ec530ba6f3..264f707bcf9c5 100644
--- a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js
+++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.js
@@ -6,6 +6,13 @@
import sinon from 'sinon';
import { Server } from 'hapi';
import { initSpacesRequestInterceptors } from './space_request_interceptors';
+import { initSelectedSpaceState } from './selected_space_state';
+
+const config = {
+ 'server.ssl.enabled': true,
+ 'server.basePath': '/',
+ 'xpack.spaces.rememberSelectedSpace': true,
+};
describe('interceptors', () => {
const sandbox = sinon.sandbox.create();
@@ -20,6 +27,12 @@ describe('interceptors', () => {
server.connection({ port: 0 });
+ initSelectedSpaceState(server, {
+ get: (key) => {
+ return config[key];
+ }
+ });
+
initSpacesRequestInterceptors(server);
server.route({
diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js
index 64b893b93239d..52dfcb255cd58 100644
--- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js
+++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js
@@ -12,6 +12,8 @@ import { spaceSchema } from '../../../lib/space_schema';
import { wrapError } from '../../../lib/errors';
import { isReservedSpace } from '../../../../common/is_reserved_space';
import { createDuplicateContextQuery } from '../../../lib/check_duplicate_context';
+import { setSelectedSpace } from '../../../lib/selected_space_state';
+import { addSpaceUrlContext } from '../../../../common';
export function initSpacesApi(server) {
const routePreCheckLicenseFn = routePreCheckLicense(server);
@@ -63,7 +65,7 @@ export function initSpacesApi(server) {
});
spaces = result.saved_objects.map(convertSavedObjectToSpace);
- } catch(e) {
+ } catch (e) {
return reply(wrapError(e));
}
@@ -156,7 +158,7 @@ export function initSpacesApi(server) {
let result;
try {
result = await client.create('space', { ...space }, { id, overwrite });
- } catch(e) {
+ } catch (e) {
return reply(wrapError(e));
}
@@ -198,10 +200,33 @@ export function initSpacesApi(server) {
}
});
+ server.route({
+ method: 'PUT',
+ path: '/api/spaces/v1/space/{id}/select',
+ async handler(request, reply) {
+ const client = request.getSavedObjectsClient();
+
+ const id = request.params.id;
+
+ try {
+ const existingSpace = await getSpaceById(client, id);
+
+ setSelectedSpace(request, reply, existingSpace.urlContext);
+ const config = server.config();
+
+ return reply({
+ location: addSpaceUrlContext(config.get('server.basePath'), existingSpace.urlContext)
+ });
+
+ } catch (e) {
+ return reply(wrapError(e));
+ }
+ }
+ });
+
async function getSpaceById(client, spaceId) {
try {
const existingSpace = await client.get('space', spaceId);
- console.log(existingSpace);
return {
id: existingSpace.id,
...existingSpace.attributes
diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js
index 2318b4cd24319..3b32d6b63f327 100644
--- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js
+++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.js
@@ -6,6 +6,7 @@
import { Server } from 'hapi';
import { initSpacesApi } from './spaces';
+import { initSelectedSpaceState } from '../../../lib/selected_space_state';
jest.mock('../../../lib/route_pre_check_license', () => {
return {
@@ -17,7 +18,7 @@ jest.mock('../../../../../../server/lib/get_client_shield', () => {
return {
getClient: () => {
return {
- callWithInternalUser: jest.fn(() => {})
+ callWithInternalUser: jest.fn(() => { })
};
}
};
@@ -48,6 +49,12 @@ describe('Spaces API', () => {
const teardowns = [];
let request;
+ const config = {
+ 'server.ssl.enabled': true,
+ 'server.basePath': '',
+ 'xpack.spaces.rememberSelectedSpace': true
+ };
+
beforeEach(() => {
request = async (method, path, setupFn = () => { }) => {
@@ -59,10 +66,11 @@ describe('Spaces API', () => {
server.decorate('server', 'config', jest.fn(() => {
return {
- get: () => ''
+ get: (key) => config[key]
};
}));
+ initSelectedSpaceState(server, server.config());
initSpacesApi(server);
server.decorate('request', 'getBasePath', jest.fn());
@@ -148,4 +156,24 @@ describe('Spaces API', () => {
message: "This Space cannot be deleted because it is reserved."
});
});
+
+ test('PUT space/{id}/select should set a cookie with the new Space, and respond with a new location', async () => {
+ const response = await request('PUT', '/api/spaces/v1/space/a-space/select');
+
+ const {
+ statusCode,
+ headers,
+ payload
+ } = response;
+
+ expect(statusCode).toEqual(200);
+
+ expect(headers).toHaveProperty('set-cookie');
+ expect(headers['set-cookie']).toHaveLength(1);
+ const [nameValuePair] = headers['set-cookie'][0].split(';').map(a => a.trim());
+ expect(nameValuePair).toEqual('selectedSpace=a-space');
+
+ const result = JSON.parse(payload);
+ expect(result.location).toEqual('/s/a-space');
+ });
});