diff --git a/docs/modules/ROOT/pages/operations.adoc b/docs/modules/ROOT/pages/operations.adoc index bd04a6d183e..a41e436280e 100644 --- a/docs/modules/ROOT/pages/operations.adoc +++ b/docs/modules/ROOT/pages/operations.adoc @@ -446,6 +446,10 @@ Separate multiple commands with a semicolon (`;`). | `browser.retain_connection_credentials` | `true` | Configure the Neo4j Browser to store or not store user credentials. + +| `browser.retain_editor_history` +| `true` +| Configure the Neo4j Browser to store or not store editor history. |=== diff --git a/src/browser/AppInit.tsx b/src/browser/AppInit.tsx index 6a5ea92b2b9..c1e4fd1adf6 100644 --- a/src/browser/AppInit.tsx +++ b/src/browser/AppInit.tsx @@ -49,7 +49,7 @@ import { APP_START } from 'shared/modules/app/appDuck' import { detectRuntimeEnv } from 'services/utils' import { NEO4J_CLOUD_DOMAINS } from 'shared/modules/settings/settingsDuck' import { version } from 'project-root/package.json' -import { allowOutgoingConnections } from 'shared/modules/dbMeta/dbMetaDuck' +import { shouldAllowOutgoingConnections } from 'shared/modules/dbMeta/dbMetaDuck' import { getUuid } from 'shared/modules/udc/udcDuck' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' @@ -167,7 +167,7 @@ export function setupSentry(): void { } }, beforeSend: event => - allowOutgoingConnections(store.getState()) + shouldAllowOutgoingConnections(store.getState()) ? scrubQueryParamsAndUrl(event) : null, environment: 'unset' diff --git a/src/browser/modules/App/App.tsx b/src/browser/modules/App/App.tsx index cb60466937d..189ac086b69 100644 --- a/src/browser/modules/App/App.tsx +++ b/src/browser/modules/App/App.tsx @@ -34,7 +34,7 @@ import { utilizeBrowserSync } from 'shared/modules/features/featuresDuck' import { getOpenDrawer } from 'shared/modules/sidebar/sidebarDuck' import { getErrorMessage } from 'shared/modules/commands/commandsDuck' import { - allowOutgoingConnections, + shouldAllowOutgoingConnections, getDatabases } from 'shared/modules/dbMeta/dbMetaDuck' import { @@ -244,7 +244,7 @@ const mapStateToProps = (state: any) => { lastConnectionUpdate: getLastConnectionUpdate(state), errorMessage: getErrorMessage(state), loadExternalScripts: - allowOutgoingConnections(state) !== false && isConnected(state), + shouldAllowOutgoingConnections(state) !== false && isConnected(state), titleString: asTitleString(connectionData), defaultConnectionData: getConnectionData(state, CONNECTION_ID), syncConsent: state.syncConsent.consented, diff --git a/src/browser/modules/Sync/BrowserSync.tsx b/src/browser/modules/Sync/BrowserSync.tsx index 4c2eb4a60eb..34dcf634531 100644 --- a/src/browser/modules/Sync/BrowserSync.tsx +++ b/src/browser/modules/Sync/BrowserSync.tsx @@ -21,7 +21,7 @@ import React, { Component } from 'react' import { connect } from 'react-redux' import TimeAgo from 'react-timeago' -import { allowOutgoingConnections } from 'shared/modules/dbMeta/dbMetaDuck' +import { shouldAllowOutgoingConnections } from 'shared/modules/dbMeta/dbMetaDuck' import { getConnectionState, DISCONNECTED_STATE @@ -282,7 +282,7 @@ const mapStateToProps = (state: any) => { browserSyncConfig: getBrowserSyncConfig(state), syncConsent: state.syncConsent.consented, connectionState: getConnectionState(state), - isAllowed: allowOutgoingConnections(state) !== false + isAllowed: shouldAllowOutgoingConnections(state) !== false } } diff --git a/src/shared/modules/dbMeta/__snapshots__/dbMetaDuck.test.ts.snap b/src/shared/modules/dbMeta/__snapshots__/dbMetaDuck.test.ts.snap index f373740de2f..35e7705fcab 100644 --- a/src/shared/modules/dbMeta/__snapshots__/dbMetaDuck.test.ts.snap +++ b/src/shared/modules/dbMeta/__snapshots__/dbMetaDuck.test.ts.snap @@ -21,6 +21,7 @@ Object { "browser.allow_outgoing_connections": false, "browser.remote_content_hostname_allowlist": "guides.neo4j.com, localhost", "browser.retain_connection_credentials": false, + "browser.retain_editor_history": false, }, } `; @@ -45,6 +46,7 @@ Object { "browser.allow_outgoing_connections": false, "browser.remote_content_hostname_allowlist": "guides.neo4j.com, localhost", "browser.retain_connection_credentials": false, + "browser.retain_editor_history": false, }, } `; diff --git a/src/shared/modules/dbMeta/dbMetaDuck.ts b/src/shared/modules/dbMeta/dbMetaDuck.ts index 8af8e092d1f..33a5209f901 100644 --- a/src/shared/modules/dbMeta/dbMetaDuck.ts +++ b/src/shared/modules/dbMeta/dbMetaDuck.ts @@ -20,6 +20,7 @@ import neo4j from 'neo4j-driver' import Rx from 'rxjs/Rx' +import semver from 'semver' import bolt from 'services/bolt/bolt' import { isConfigValFalsy } from 'services/bolt/boltHelpers' import { APP_START } from 'shared/modules/app/appDuck' @@ -59,6 +60,7 @@ import { isACausalCluster, setClientConfig } from '../features/featuresDuck' +import { clearHistory } from 'shared/modules/history/historyDuck' export const NAME = 'meta' export const UPDATE = 'meta/UPDATE' @@ -96,6 +98,8 @@ export function getMetaInContext(state: any, context: any) { export const getVersion = (state: any) => (state[NAME] || {}).server ? (state[NAME] || {}).server.version : 0 export const getEdition = (state: any) => state[NAME].server.edition +export const hasEdition = (state: any) => + state[NAME].server.edition !== initialState.server.edition export const getStoreSize = (state: any) => state[NAME].server.storeSize export const getClusterRole = (state: any) => state[NAME].role export const isEnterprise = (state: any) => @@ -106,7 +110,7 @@ export const getStoreId = (state: any) => export const getAvailableSettings = (state: any) => (state[NAME] || initialState).settings -export const allowOutgoingConnections = (state: any) => +export const getAllowOutgoingConnections = (state: any) => getAvailableSettings(state)['browser.allow_outgoing_connections'] export const credentialsTimeout = (state: any) => getAvailableSettings(state)['browser.credential_timeout'] || 0 @@ -114,12 +118,18 @@ export const getRemoteContentHostnameAllowlist = (state: any) => getAvailableSettings(state)['browser.remote_content_hostname_allowlist'] export const getDefaultRemoteContentHostnameAllowlist = (_state: any) => initialState.settings['browser.remote_content_hostname_allowlist'] -export const shouldRetainConnectionCredentials = (state: any) => { +export const getRetainConnectionCredentials = (state: any) => { const settings = getAvailableSettings(state) const conf = settings['browser.retain_connection_credentials'] if (conf === null || typeof conf === 'undefined') return false return !isConfigValFalsy(conf) } +export const getRetainEditorHistory = (state: any) => { + const settings = getAvailableSettings(state) + const conf = settings['browser.retain_editor_history'] + if (conf === null || typeof conf === 'undefined') return false + return !isConfigValFalsy(conf) +} export const getDatabases = (state: any) => (state[NAME] || initialState).databases export const getActiveDbName = (state: any) => @@ -128,6 +138,24 @@ export const getActiveDbName = (state: any) => * Helpers */ +export const VERSION_FOR_EDITOR_HISTORY_SETTING = '4.3.0' + +export const versionHasEditorHistorySetting = (version: string) => + semver.gte(version, VERSION_FOR_EDITOR_HISTORY_SETTING) + +export const supportsEditorHistorySetting = (state: any) => + isEnterprise(state) && versionHasEditorHistorySetting(getVersion(state)) + +export const shouldAllowOutgoingConnections = (state: any) => + (hasEdition(state) && !isEnterprise(state)) || + getAllowOutgoingConnections(state) + +export const shouldRetainConnectionCredentials = (state: any) => + !isEnterprise(state) || getRetainConnectionCredentials(state) + +export const shouldRetainEditorHistory = (state: any) => + !supportsEditorHistorySetting(state) || getRetainEditorHistory(state) + function updateMetaForContext(state: any, meta: any, context: any) { if (!meta || !meta.records || !meta.records.length) { return { @@ -216,7 +244,8 @@ export const initialState = { settings: { 'browser.allow_outgoing_connections': false, 'browser.remote_content_hostname_allowlist': 'guides.neo4j.com, localhost', - 'browser.retain_connection_credentials': false + 'browser.retain_connection_credentials': false, + 'browser.retain_editor_history': false } } @@ -605,6 +634,13 @@ export const serverConfigEpic = (some$: any, store: any) => } store.dispatch(setRetainCredentials(retainCredentials)) value = retainCredentials + } else if (name === 'browser.retain_editor_history') { + let retainHistory = true + // Check if we should wipe user history from localstorage + if (typeof value !== 'undefined' && isConfigValFalsy(value)) { + retainHistory = false + } + value = retainHistory } else if (name === 'browser.allow_outgoing_connections') { // Use isConfigValFalsy to cast undefined to true value = !isConfigValFalsy(value) @@ -623,6 +659,10 @@ export const serverConfigEpic = (some$: any, store: any) => updateUserCapability(USER_CAPABILITIES.serverConfigReadable, true) ) store.dispatch(updateSettings(settings)) + // settings must be updated in state for this check to work + if (!shouldRetainEditorHistory(store.getState())) { + store.dispatch(clearHistory()) + } return Rx.Observable.of(null) }) }) diff --git a/src/shared/services/localstorage.test.ts b/src/shared/services/localstorage.test.ts index bb817ed57e6..0302ef326c0 100644 --- a/src/shared/services/localstorage.test.ts +++ b/src/shared/services/localstorage.test.ts @@ -97,7 +97,10 @@ describe('localstorage', () => { }) describe('localstorage redux middleware', () => { - const createAndInvokeMiddlewareWithRetainFlag = (retain: boolean) => { + const createAndInvokeMiddlewareWithRetainConnectionFlag = ( + retain: boolean, + edition = 'enterprise' + ) => { const setItemMock = jest.fn() ls.applyKeys('connections') ls.setStorage(({ @@ -109,6 +112,9 @@ describe('localstorage', () => { connectionsById: { $$discovery: { password: 'secret password' } } }, meta: { + server: { + edition + }, settings: { 'browser.retain_connection_credentials': retain } @@ -128,7 +134,9 @@ describe('localstorage', () => { } it('removes passwords from connection data if browser.retain_connection_credentials is false', () => { - const setItemMock = createAndInvokeMiddlewareWithRetainFlag(false) + const setItemMock = createAndInvokeMiddlewareWithRetainConnectionFlag( + false + ) expect(setItemMock).toHaveBeenCalledWith( 'neo4j.connections', @@ -139,7 +147,9 @@ describe('localstorage', () => { }) it('retains passwords in connection data if browser.retain_connection_credentials is true', () => { - const setItemMock = createAndInvokeMiddlewareWithRetainFlag(true) + const setItemMock = createAndInvokeMiddlewareWithRetainConnectionFlag( + true + ) expect(setItemMock).toHaveBeenCalledWith( 'neo4j.connections', @@ -148,5 +158,103 @@ describe('localstorage', () => { }) ) }) + + const existingHistory = ['history item'] + + const createAndInvokeMiddlewareWithRetainHistoryFlag = ({ + retain, + edition = 'enterprise', + version = '4.3.0' + }: { + retain?: boolean + edition?: string + version?: string + }) => { + const setItemMock = jest.fn() + ls.applyKeys('history') + ls.setStorage(({ + setItem: setItemMock + } as Partial) as Storage) + + const state = { + history: existingHistory, + meta: { + server: { + version, + edition + }, + settings: { + 'browser.retain_editor_history': retain + } + }, + connections: { + connectionsById: { $$discovery: { password: 'secret password' } } + } + } + + const store = { + getState: () => state, + dispatch: jest.fn() + } + const next = jest.fn(action => action) + const action = { type: 'some action' } + + ls.createReduxMiddleware()(store)(next)(action) + + return setItemMock + } + + it('removes history from data if browser.retain_editor_history is false', () => { + const setItemMock = createAndInvokeMiddlewareWithRetainHistoryFlag({ + retain: false + }) + + expect(setItemMock).toHaveBeenCalledWith( + 'neo4j.history', + JSON.stringify([]) + ) + }) + + it('retains history from data if browser.retain_editor_history is true', () => { + const setItemMock = createAndInvokeMiddlewareWithRetainHistoryFlag({ + retain: true + }) + + expect(setItemMock).toHaveBeenCalledWith( + 'neo4j.history', + JSON.stringify(existingHistory) + ) + }) + + it('removes history from data if browser.retain_editor_history is undefined and server version is >= 4.3.0', () => { + const setItemMock = createAndInvokeMiddlewareWithRetainHistoryFlag({}) + + expect(setItemMock).toHaveBeenCalledWith( + 'neo4j.history', + JSON.stringify([]) + ) + }) + + it('retains history from data if browser.retain_editor_history is undefined and server version is < 4.3.0', () => { + const setItemMock = createAndInvokeMiddlewareWithRetainHistoryFlag({ + version: '4.2.0' + }) + + expect(setItemMock).toHaveBeenCalledWith( + 'neo4j.history', + JSON.stringify(existingHistory) + ) + }) + + it('retains history from data if browser.retain_editor_history is undefined and server edition is not enterprise', () => { + const setItemMock = createAndInvokeMiddlewareWithRetainHistoryFlag({ + edition: 'community' + }) + + expect(setItemMock).toHaveBeenCalledWith( + 'neo4j.history', + JSON.stringify(existingHistory) + ) + }) }) }) diff --git a/src/shared/services/localstorage.ts b/src/shared/services/localstorage.ts index bf1505ed77e..a0e28cad49d 100644 --- a/src/shared/services/localstorage.ts +++ b/src/shared/services/localstorage.ts @@ -20,7 +20,10 @@ import { Middleware } from 'redux' import { GlobalState } from 'shared/globalState' -import { shouldRetainConnectionCredentials } from '../modules/dbMeta/dbMetaDuck' +import { + shouldRetainConnectionCredentials, + shouldRetainEditorHistory +} from '../modules/dbMeta/dbMetaDuck' import { initialState as settingsInitialState } from '../modules/settings/settingsDuck' export const keyPrefix = 'neo4j.' @@ -99,6 +102,8 @@ export function createReduxMiddleware(): Middleware { ) ) }) + } else if (key === 'history' && !shouldRetainEditorHistory(state)) { + setItem(key, []) } else { setItem(key, state[key]) }