diff --git a/.github/workflows/master-deployment.yml b/.github/workflows/master-deployment.yml index b170bcc03..a8a966f2a 100644 --- a/.github/workflows/master-deployment.yml +++ b/.github/workflows/master-deployment.yml @@ -79,7 +79,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:2.4.3 + tags: ${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:2.4.4 build-docker-legacy: needs: build-test runs-on: neodash-runners @@ -103,7 +103,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.4.3 + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.4.4 deploy-gallery: runs-on: neodash-runners strategy: diff --git a/Dockerfile b/Dockerfile index c2f4fa361..a5c9fcf56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,4 +43,4 @@ USER nginx EXPOSE $NGINX_PORT HEALTHCHECK cmd curl --fail "http://localhost:$NGINX_PORT" || exit 1 -LABEL version="2.4.3" +LABEL version="2.4.4" diff --git a/changelog.md b/changelog.md index 1542e992d..c80e24157 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,12 @@ +## NeoDash 2.4.4 +This is a hotfix release fixing some breaking issues in the 2.4.3: +- Fixed number parsing using newer versions of the Neo4j driver. [811](https://github.com/neo4j-labs/neodash/pull/811) +- Reverted new connection handler for auto-renewed SSO sessions. [815](https://github.com/neo4j-labs/neodash/pull/815) +- Improved handling of parameters in form extension, resolved local state issues. [813](https://github.com/neo4j-labs/neodash/pull/813) +- Updated Role management extension to no longer execute queries in parallel, improved UX and error handling [813](https://github.com/neo4j-labs/neodash/pull/813) + +If you are currently using NeoDash version 2.4.3, we recommend updating as soon as possible. + ## NeoDash 2.4.3 This release contains several improvements and additions to multi-dashboard management, as well as a bug fixes and a variety of quality-of-life improvements: diff --git a/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc b/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc index 8f25da594..d77d7e8b6 100644 --- a/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc +++ b/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc @@ -37,7 +37,7 @@ Depending on the webserver type and version, this could be different directory. As an example - to copy the files to an nginx webserver using `scp`: ```bash -scp neodash-2.4.3 username@host:/usr/share/nginx/html +scp neodash-2.4.4 username@host:/usr/share/nginx/html ``` NeoDash should now be visible by visiting your (sub)domain in the browser. diff --git a/package.json b/package.json index 7632e3c79..a5cc534df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neodash", - "version": "2.4.3", + "version": "2.4.4", "description": "NeoDash - Neo4j Dashboard Builder", "neo4jDesktop": { "apiVersion": "^1.2.0" @@ -72,7 +72,6 @@ "mui-color": "^2.0.0-beta.2", "mui-nested-menu": "^3.2.1", "neo4j-client-sso": "^1.2.2", - "neo4j-driver": "^5.12.0", "openai": "^3.3.0", "postcss": "^8.4.21", "postcss-loader": "^7.2.4", diff --git a/release-notes.md b/release-notes.md index bfa9e6f9e..2b364ba45 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,27 +1,8 @@ -## NeoDash 2.4.3 -This release contains several improvements and additions to multi-dashboard management, as well as a bug fixes and a variety of quality-of-life improvements: - -Dashboard management and access control: -- Added a UI for handling dashboard access using RBAC, as well as a new extension to simply access control. -- Added button to sidebar to refresh the list of dashboards saved in the database. -- Improved handling and detection of draft dashboards in the dashboard sidebar. - -Other improvements: -- Changed CSV export functionality for tables to use UTF-8 format. -- Various improvements / fixes to the documentation to include new images, and up-to-date functionality. -- Added logic for handling refresh tokens when connected to NeoDash via SSO. -- Incorporated tooltips for bar charts with and without custom labels. - -Bug fixes and testing: -- Implemented bug fixes on type casting for numeric parameter selectors. -- Fixed issue with report actions not functioning properly on node click events. -- Extended test suite with Cypress tests for advanced settings in the bar chart. - -Thanks to all the contributors for this release: -- [OskarDamkjaer](https://github.com/OskarDamkjaer) -- [alfredorubin96](https://github.com/alfredorubin96), -- [AleSim94](https://github.com/AleSim94), -- [BennuFire](https://github.com/BennuFire), -- [jacobbleakley-neo4j](https://github.com/jacobbleakley-neo4j), -- [josepmonclus](https://github.com/josepmonclus) -- [nielsdejong](https://github.com/nielsdejong) \ No newline at end of file +## NeoDash 2.4.4 +This is a hotfix release fixing some breaking issues in the 2.4.3: +- Fixed number parsing using newer versions of the Neo4j driver. [811](https://github.com/neo4j-labs/neodash/pull/811) +- Reverted new connection handler for auto-renewed SSO sessions. [815](https://github.com/neo4j-labs/neodash/pull/815) +- Improved handling of parameters in form extension, resolved local state issues. [813](https://github.com/neo4j-labs/neodash/pull/813) +- Updated Role management extension to no longer execute queries in parallel, improved UX and error handling [813](https://github.com/neo4j-labs/neodash/pull/813) + +If you are currently using NeoDash version 2.4.3, we recommend updating as soon as possible. \ No newline at end of file diff --git a/src/application/ApplicationActions.ts b/src/application/ApplicationActions.ts index 5d7f815dc..0b556f2dc 100644 --- a/src/application/ApplicationActions.ts +++ b/src/application/ApplicationActions.ts @@ -2,8 +2,6 @@ * This file contains all state-changing actions relevant for the main application. */ -import { SSOProviderOriginal } from 'neo4j-client-sso'; - export const CLEAR_NOTIFICATION = 'APPLICATION/CLEAR_NOTIFICATION'; export const clearNotification = () => ({ type: CLEAR_NOTIFICATION, @@ -58,11 +56,10 @@ export const setConnectionProperties = ( port: string, database: string, username: string, - password: string, - ssoProviders?: SSOProviderOriginal[] + password: string ) => ({ type: SET_CONNECTION_PROPERTIES, - payload: { protocol, url, port, database, username, password, ssoProviders }, + payload: { protocol, url, port, database, username, password }, }); export const SET_BASIC_CONNECTION_PROPERTIES = 'APPLICATION/SET_BASIC_CONNECTION_PROPERTIES'; diff --git a/src/application/ApplicationReducer.ts b/src/application/ApplicationReducer.ts index 590e930db..e662ade63 100644 --- a/src/application/ApplicationReducer.ts +++ b/src/application/ApplicationReducer.ts @@ -64,7 +64,6 @@ const initialState = { database: '', username: 'neo4j', password: '', - ssoProviders: [], }, shareDetails: undefined, desktopConnection: null, @@ -247,7 +246,7 @@ export const applicationReducer = (state = initialState, action: { type: any; pa return state; } case SET_CONNECTION_PROPERTIES: { - const { protocol, url, port, database, username, password, ssoProviders } = payload; + const { protocol, url, port, database, username, password } = payload; state = update(state, { connection: { protocol: protocol, @@ -256,7 +255,6 @@ export const applicationReducer = (state = initialState, action: { type: any; pa database: database, username: username, password: password, - ssoProviders, }, }); return state; diff --git a/src/application/ApplicationThunks.ts b/src/application/ApplicationThunks.ts index 965d7fc78..fc880fd3a 100644 --- a/src/application/ApplicationThunks.ts +++ b/src/application/ApplicationThunks.ts @@ -1,3 +1,4 @@ +import { createDriver } from 'use-neo4j'; import { initializeSSO } from '../component/sso/SSOUtils'; import { DEFAULT_SCREEN, Screens } from '../config/ApplicationConfig'; import { setDashboard } from '../dashboard/DashboardActions'; @@ -43,9 +44,6 @@ import { } from './ApplicationActions'; import { setLoggingMode, setLoggingDatabase, setLogErrorNotification } from './logging/LoggingActions'; import { version } from '../modal/AboutModal'; -import neo4j, { auth, authTokenManagers } from 'neo4j-driver'; -import type { Neo4jScheme } from 'use-neo4j/dist/neo4j-config.interface'; -import { SSOProviderOriginal, handleRefreshingToken } from 'neo4j-client-sso'; import { applicationIsStandalone } from './ApplicationSelectors'; import { applicationGetLoggingSettings } from './logging/LoggingSelectors'; import { createLogThunk } from './logging/LoggingThunk'; @@ -56,47 +54,6 @@ import { createUUID } from '../utils/uuid'; * Several actions/other thunks may be dispatched from here. */ -export const createDriver = ( - scheme: Neo4jScheme, - host: string, - port: string | number, - username?: string, - password?: string, - config?: { userAgent?: string }, - ssoProviders: SSOProviderOriginal[] = [] -) => { - if (ssoProviders.length > 0) { - const authTokenMgr = authTokenManagers.bearer({ - tokenProvider: async () => { - const credentials = await handleRefreshingToken(ssoProviders); - const token = auth.bearer(credentials.password); - // Get the expiration from the JWT's payload, which is a JSON string encoded - // using base64. You could also use a JWT parsing lib - const [, payloadBase64] = credentials.password.split('.'); - const payload: unknown = JSON.parse(window.atob(payloadBase64 ?? '')); - let expiration: Date; - if (typeof payload === 'object' && payload !== null && 'exp' in payload) { - expiration = new Date(Number(payload.exp) * 1000); - } else { - expiration = new Date(); - } - - return { - expiration, - token, - }; - }, - }); - return neo4j.driver(`${scheme}://${host}:${port}`, authTokenMgr, config); - } - - if (!username || !password) { - return neo4j.driver(`${scheme}://${host}:${port}`); - } - - return neo4j.driver(`${scheme}://${host}:${port}`, neo4j.auth.basic(username, password), config); -}; - /** * Establish a connection to Neo4j with the specified credentials. Open/close the relevant windows when connection is made (un)successfully. * @param protocol - the neo4j protocol (e.g. bolt, bolt+s, neo4j+s, ...) @@ -105,24 +62,14 @@ export const createDriver = ( * @param database - the Neo4j database to connect to. * @param username - Neo4j username. * @param password - Neo4j password. - * @param SSOProviders - List of available SSO providers */ export const createConnectionThunk = - (protocol, url, port, database, username, password, SSOProviders = []) => - (dispatch: any, getState: any) => { + (protocol, url, port, database, username, password) => (dispatch: any, getState: any) => { const loggingState = getState(); const loggingSettings = applicationGetLoggingSettings(loggingState); const neodashMode = applicationIsStandalone(loggingState) ? 'Standalone' : 'Editor'; try { - const driver = createDriver( - protocol, - url, - port, - username, - password, - { userAgent: `neodash/v${version}` }, - SSOProviders - ); + const driver = createDriver(protocol, url, port, username, password, { userAgent: `neodash/v${version}` }); // eslint-disable-next-line no-console console.log('Attempting to connect...'); const validateConnection = (records) => { @@ -561,7 +508,7 @@ export const loadApplicationConfigThunk = () => async (dispatch: any, getState: dispatch(setAboutModalOpen(false)); dispatch(setConnected(false)); dispatch(setWelcomeScreenOpen(false)); - const success = await initializeSSO(state.application.cachedSSODiscoveryUrl, (credentials, ssoProviders) => { + const success = await initializeSSO(state.application.cachedSSODiscoveryUrl, (credentials) => { if (standalone) { // Redirected from SSO and running in viewer mode, merge retrieved config with hardcoded credentials. dispatch( @@ -571,8 +518,7 @@ export const loadApplicationConfigThunk = () => async (dispatch: any, getState: config.standalonePort, config.standaloneDatabase, credentials.username, - credentials.password, - ssoProviders + credentials.password ) ); dispatch( @@ -582,8 +528,7 @@ export const loadApplicationConfigThunk = () => async (dispatch: any, getState: config.standalonePort, config.standaloneDatabase, credentials.username, - credentials.password, - ssoProviders + credentials.password ) ); } else { @@ -595,8 +540,7 @@ export const loadApplicationConfigThunk = () => async (dispatch: any, getState: state.application.connection.port, state.application.connection.database, credentials.username, - credentials.password, - ssoProviders + credentials.password ) ); dispatch(setConnected(true)); diff --git a/src/chart/ChartUtils.ts b/src/chart/ChartUtils.ts index cd6feb8c0..dd7eaf6b4 100644 --- a/src/chart/ChartUtils.ts +++ b/src/chart/ChartUtils.ts @@ -95,7 +95,7 @@ export function valueIsObject(value) { } export function toNumber(ref) { - if (ref === undefined) { + if (ref === undefined || typeof ref === 'number') { return ref; } let { low, high } = ref; @@ -172,7 +172,7 @@ export const downloadCSV = (rows) => { }); csv += '\n'; }); - const file = new Blob([`\ufeff${ csv}`], { type: 'text/plain;charset=utf8' }); + const file = new Blob([`\ufeff${csv}`], { type: 'text/plain;charset=utf8' }); element.href = URL.createObjectURL(file); element.download = 'table.csv'; document.body.appendChild(element); // Required for this to work in FireFox diff --git a/src/chart/graph/util/RecordUtils.ts b/src/chart/graph/util/RecordUtils.ts index f432e6596..b8d853d33 100644 --- a/src/chart/graph/util/RecordUtils.ts +++ b/src/chart/graph/util/RecordUtils.ts @@ -1,6 +1,6 @@ import { evaluateRulesOnNode, evaluateRulesOnLink } from '../../../extensions/styling/StyleRuleEvaluator'; import { extractNodePropertiesFromRecords, mergeNodePropsFieldsLists } from '../../../report/ReportRecordProcessing'; -import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath } from '../../ChartUtils'; +import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath, toNumber } from '../../ChartUtils'; import { GraphChartVisualizationProps } from '../GraphChartVisualization'; import { assignCurvatureToLink } from './RelUtils'; import { isNode } from 'neo4j-driver-core/lib/graph-types.js'; @@ -49,7 +49,9 @@ function extractGraphEntitiesFromField( nodes[value.identity.low] = { id: value.identity.low, labels: value.labels, - size: value.properties[nodeSizeProperty] ? value.properties[nodeSizeProperty] : defaultNodeSize, + size: !Number.isNaN(value.properties[nodeSizeProperty]) + ? toNumber(value.properties[nodeSizeProperty]) + : defaultNodeSize, properties: value.properties, mainLabel: value.labels[value.labels.length - 1], }; @@ -67,7 +69,9 @@ function extractGraphEntitiesFromField( source: value.start.low, target: value.end.low, type: value.type, - width: value.properties[relWidthProperty] ? value.properties[relWidthProperty] : defaultRelWidth, + width: !Number.isNaN(value.properties[relWidthProperty]) + ? toNumber(value.properties[relWidthProperty]) + : defaultRelWidth, color: value.properties[relColorProperty] ? value.properties[relColorProperty] : defaultRelColor, properties: value.properties, }); diff --git a/src/component/sso/SSOUtils.ts b/src/component/sso/SSOUtils.ts index b9b6f038c..c25e0d086 100644 --- a/src/component/sso/SSOUtils.ts +++ b/src/component/sso/SSOUtils.ts @@ -118,7 +118,7 @@ export const initializeSSO = async (cachedSSODiscoveryUrl, _setCredentials) => { // Successful credentials retrieval. // Log in at the Neo4j dbms now using the Neo4j (js) driver. // - _setCredentials(credentials, mergedSSOProviders); + _setCredentials(credentials); // Exemplifying retrieval of stored URL paramenters _retrieveAdditionalURLParameters(); diff --git a/src/dashboard/Dashboard.tsx b/src/dashboard/Dashboard.tsx index acc4bb712..1039fce91 100644 --- a/src/dashboard/Dashboard.tsx +++ b/src/dashboard/Dashboard.tsx @@ -3,7 +3,7 @@ import NeoPage from '../page/Page'; import NeoDashboardHeader from './header/DashboardHeader'; import NeoDashboardTitle from './header/DashboardTitle'; import NeoDashboardHeaderPageList from './header/DashboardHeaderPageList'; -import { Neo4jProvider } from 'use-neo4j'; +import { createDriver, Neo4jProvider } from 'use-neo4j'; import { applicationGetConnection, applicationGetStandaloneSettings } from '../application/ApplicationSelectors'; import { connect } from 'react-redux'; import NeoDashboardConnectionUpdateHandler from '../component/misc/DashboardConnectionUpdateHandler'; @@ -11,7 +11,6 @@ import { forceRefreshPage } from '../page/PageActions'; import { getPageNumber } from '../settings/SettingsSelectors'; import { createNotificationThunk } from '../page/PageThunks'; import { version } from '../modal/AboutModal'; -import { createDriver } from '../application/ApplicationThunks'; import NeoDashboardSidebar from './sidebar/DashboardSidebar'; const Dashboard = ({ @@ -33,10 +32,8 @@ const Dashboard = ({ connection.port, connection.username, connection.password, - { userAgent: `neodash/v${version}` }, - connection.ssoProviders + { userAgent: `neodash/v${version}` } ); - // @ts-ignore wrong driver version setDriver(newDriver); } const content = ( diff --git a/src/extensions/advancedcharts/chart/gantt/Utils.ts b/src/extensions/advancedcharts/chart/gantt/Utils.ts index 012c77201..331be20ff 100644 --- a/src/extensions/advancedcharts/chart/gantt/Utils.ts +++ b/src/extensions/advancedcharts/chart/gantt/Utils.ts @@ -1,3 +1,4 @@ +import { toNumber } from '../../../../chart/ChartUtils'; import { buildGraphVisualizationObjectFromRecords } from '../../../../chart/graph/util/RecordUtils'; import date_utils from './frappe/lib/date_utils'; @@ -97,9 +98,9 @@ export function createTasksList( return undefined; } } - return { - start: new Date(neoStartDate.year, neoStartDate.month, neoStartDate.day), - end: new Date(neoEndDate.year, neoEndDate.month, neoEndDate.day), + let res = { + start: new Date(toNumber(neoStartDate.year), toNumber(neoStartDate.month), toNumber(neoStartDate.day)), + end: new Date(toNumber(neoEndDate.year), toNumber(neoEndDate.month), toNumber(neoEndDate.day)), name: name || '(undefined)', labels: n.labels, dependencies: dependencies[n.id], @@ -112,6 +113,7 @@ export function createTasksList( isDisabled: true, styles: { progressColor: '#ffbb54', progressSelectedColor: '#ff9e0d' }, }; + return res; }) .filter((i) => i !== undefined); } diff --git a/src/extensions/forms/FormsReportConfig.tsx b/src/extensions/forms/FormsReportConfig.tsx index 4367a7497..5918a4425 100644 --- a/src/extensions/forms/FormsReportConfig.tsx +++ b/src/extensions/forms/FormsReportConfig.tsx @@ -46,7 +46,7 @@ export const FORMS = { label: 'Clear parameters after submit', type: SELECTION_TYPES.LIST, values: [true, false], - default: false, + default: true, }, hasResetButton: { label: 'Has Reset Button', diff --git a/src/extensions/forms/chart/NeoForm.tsx b/src/extensions/forms/chart/NeoForm.tsx index e8d33641e..9ae9c9109 100644 --- a/src/extensions/forms/chart/NeoForm.tsx +++ b/src/extensions/forms/chart/NeoForm.tsx @@ -26,7 +26,7 @@ const NeoForm = (props: ChartProps) => { const hasResetButton = settings?.hasResetButton ?? true; const hasSubmitButton = settings?.hasSubmitButton ?? true; const hasSubmitMessage = settings?.hasSubmitMessage ?? true; - const clearParametersAfterSubmit = settings?.clearParametersAfterSubmit ?? false; + const clearParametersAfterSubmit = settings?.clearParametersAfterSubmit ?? true; const [submitButtonActive, setSubmitButtonActive] = React.useState(true); const [status, setStatus] = React.useState(FormStatus.DATA_ENTRY); const [formResults, setFormResults] = React.useState([]); diff --git a/src/extensions/forms/settings/NeoFormCardSettingsModal.tsx b/src/extensions/forms/settings/NeoFormCardSettingsModal.tsx index fc9fec0ad..87515c24b 100644 --- a/src/extensions/forms/settings/NeoFormCardSettingsModal.tsx +++ b/src/extensions/forms/settings/NeoFormCardSettingsModal.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { Button, Dialog } from '@neo4j-ndl/react'; import ParameterSelectCardSettings from '../../../chart/parameter/ParameterSelectCardSettings'; import NeoCardSettingsFooter from '../../../card/settings/CardSettingsFooter'; +import { objMerge } from '../../../utils/ObjectManipulation'; const NeoFormCardSettingsModal = ({ open, setOpen, index, formFields, setFormFields, database, extensions }) => { const [advancedSettingsOpen, setAdvancedSettingsOpen] = React.useState(false); @@ -24,15 +25,19 @@ const NeoFormCardSettingsModal = ({ open, setOpen, index, formFields, setFormFie query={formFields[index].query} type={'select'} database={database} - settings={formFields[index].settings} + settings={objMerge({ inputMode: 'cypher' }, formFields[index].settings)} extensions={extensions} onReportSettingUpdate={(key, value) => { const newFormFields = [...formFields]; newFormFields[index].settings[key] = value; + if (key == 'type') { + newFormFields[index].type = value; + } setFormFields(newFormFields); }} onQueryUpdate={(query) => { const newFormFields = [...formFields]; + newFormFields[index].query = query; setFormFields(newFormFields); }} diff --git a/src/extensions/rbac/RBACManagementMenu.tsx b/src/extensions/rbac/RBACManagementMenu.tsx index 264f84643..1a85b08fe 100644 --- a/src/extensions/rbac/RBACManagementMenu.tsx +++ b/src/extensions/rbac/RBACManagementMenu.tsx @@ -19,7 +19,7 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti if (!MenuOpen) { return; } - const query = `SHOW ROLES YIELD role WHERE role <> "PUBLIC" return role`; + const query = `SHOW PRIVILEGES YIELD role, action WHERE role <> "PUBLIC" RETURN role, 'dbms_actions' in collect(action)`; runCypherQuery( driver, 'system', @@ -32,7 +32,8 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti createNotification('Unable to retrieve roles', records[0].error); return; } - setRoles(records.map((record) => record._fields[0])); + // Only display roles which are not able to do 'dbms_actions', i.e. they are not admins. + setRoles(records.filter((r) => r._fields[1] == false).map((record) => record._fields[0])); } ); }, [MenuOpen]); @@ -71,7 +72,7 @@ export const RBACManagementMenu = ({ anchorEl, MenuOpen, handleClose, createNoti { setIsModalOpen(false); }} diff --git a/src/extensions/rbac/RBACManagementModal.tsx b/src/extensions/rbac/RBACManagementModal.tsx index c19f5ea21..f3f17a08c 100644 --- a/src/extensions/rbac/RBACManagementModal.tsx +++ b/src/extensions/rbac/RBACManagementModal.tsx @@ -26,6 +26,12 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti const [labels, setLabels] = useState([]); const [allowList, setAllowList] = useState([]); const [denyList, setDenyList] = useState([]); + const [fixedAllowList, setFixedAllowList] = useState([]); + const [fixedDenyList, setFixedDenyList] = useState([]); + const [denyCompleted, setDenyCompleted] = useState(false); + const [allowCompleted, setAllowCompleted] = useState(false); + const [usersCompleted, setUsersCompleted] = useState(false); + const [failed, setFailed] = useState(false); useEffect(() => { if (!open) { @@ -35,10 +41,22 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti setSelectedDatabase(''); return; } + setDenyCompleted(false); + setAllowCompleted(false); + setUsersCompleted(false); + setFailed(false); retrieveDatabaseList(driver, setDatabases); retrieveNeo4jUsers(driver, currentRole, setNeo4jUsers, setSelectedUsers); }, [open]); + useEffect(() => { + if (failed !== false) { + createNotification('Unable to update privileges', `${failed}`); + } else if (denyCompleted && allowCompleted && usersCompleted) { + createNotification('Success', `Access for role '${currentRole}' updated.`); + } + }, [denyCompleted, allowCompleted, usersCompleted, failed]); + const parseLabelsList = (database, records) => { const allLabels = records.map((record) => record._fields[0]).filter((l) => l !== '_Neodash_Dashboard'); retrieveAllowAndDenyLists( @@ -49,6 +67,8 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti setLabels, setAllowList, setDenyList, + setFixedAllowList, + setFixedDenyList, setLoaded ); }; @@ -58,16 +78,48 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti retrieveLabelsList(driver, selectedOption.value, (records) => parseLabelsList(selectedOption.value, records)); }; - const handleSave = () => { - updateUsers(driver, currentRole, neo4jUsers, selectedUsers); + const handleSave = async () => { + createNotification('Updating', `Access for role '${currentRole}' is being updated, please wait...`); + console.log(selectedUsers); + updateUsers( + driver, + currentRole, + neo4jUsers, + selectedUsers, + () => setUsersCompleted(true), + (failReason) => setFailed(`Operation 'ROLE-USER ASSIGNMENT' failed.\n Reason: ${failReason}`) + ); + if (selectedDatabase) { - createNotification('Updating', `Access for role '${currentRole}' is being updated, please wait...`); - updatePrivileges(driver, selectedDatabase, currentRole, labels, denyList, Operation.DENY, createNotification); - updatePrivileges(driver, selectedDatabase, currentRole, labels, allowList, Operation.GRANT, createNotification); + const nonFixedDenyList = denyList.filter((n) => !fixedDenyList.includes(n)); + const nonFixedAllowList = allowList.filter((n) => !fixedDenyList.includes(n)); + updatePrivileges( + driver, + selectedDatabase, + currentRole, + labels, + nonFixedDenyList, + Operation.DENY, + () => setDenyCompleted(true), + (failReason) => setFailed(`Operation 'DENY LABEL ACCESS' failed.\n Reason: ${failReason}`) + ).then(() => { + updatePrivileges( + driver, + selectedDatabase, + currentRole, + labels, + nonFixedAllowList, + Operation.GRANT, + () => setAllowCompleted(true), + (failReason) => setFailed(`Operation 'ALLOW LABEL ACCESS' failed.\n Reason: ${failReason}`) + ); + }); } else { - createNotification('Success', `Users have been updated for role '${currentRole}'.`); + // Since there is no database selected, we don't run the DENY/ALLOW queries. + // We just mark them as completed so the success message shows up. + setDenyCompleted(true); + setAllowCompleted(true); } - handleClose(); }; @@ -138,7 +190,17 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti value: allowList.map((nodelabel) => ({ value: nodelabel, label: nodelabel })), options: labels.map((nodelabel) => ({ value: nodelabel, label: nodelabel })), isMulti: true, - onChange: (val) => setAllowList(val.map((v) => v.value)), + onChange: (val) => { + // Make sure that only database-specific label access rules can be changed from this UI. + if (fixedAllowList.every((v) => val.map((selected) => selected.value).includes(v))) { + setAllowList(val.map((v) => v.value)); + } else { + createNotification( + 'Label cannot be removed', + 'The selected label is allowed access across all databases. You cannot remove this privilege using this interface.' + ); + } + }, }} /> @@ -154,9 +216,21 @@ export const RBACManagementModal = ({ open, handleClose, currentRole, createNoti placeholder: 'Select labels', isClearable: false, value: denyList.map((nodelabel) => ({ value: nodelabel, label: nodelabel })), - options: labels.map((nodelabel) => ({ value: nodelabel, label: nodelabel })), + options: labels + .filter((l) => l !== '*') + .map((nodelabel) => ({ value: nodelabel, label: nodelabel })), isMulti: true, - onChange: (val) => setDenyList(val.map((v) => v.value)), + onChange: (val) => { + // Make sure that only database-specific label access rules can be changed from this UI. + if (fixedDenyList.every((v) => val.map((selected) => selected.value).includes(v))) { + setDenyList(val.map((v) => v.value)); + } else { + createNotification( + 'Label cannot be removed', + 'The selected label is denied access across all databases. You cannot remove this privilege using this interface.' + ); + } + }, }} /> diff --git a/src/extensions/rbac/RBACUtils.ts b/src/extensions/rbac/RBACUtils.ts index 0ed54781b..69e701bb9 100644 --- a/src/extensions/rbac/RBACUtils.ts +++ b/src/extensions/rbac/RBACUtils.ts @@ -15,15 +15,16 @@ export enum Operation { * @param newLabels list of new labels in the database, for which priveleges are changed. * @param operation The operation, either 'GRANT' or 'DENY' */ -export const updatePrivileges = ( +export async function updatePrivileges( driver, database, role, allLabels, newLabels, operation: Operation, - createNotification -) => { + onSuccess, + onFail +) { // TODO - should we also drop cross-database DENYs (`ON GRAPH *`) to catch the true full set? // TODO - there // 1. Special case for '*'. Create it if needed to be there, otherwise revoke it. @@ -49,7 +50,7 @@ export const updatePrivileges = ( {}, 1000, (status) => { - if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) { + if (status == QueryStatus.NO_DATA || status == QueryStatus.COMPLETE) { // TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query. // We build in an artificial delay... const timeout = setTimeout(() => { @@ -67,21 +68,38 @@ export const updatePrivileges = ( ), {}, 1000, - () => { - if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) { - createNotification('Success', `Access for role '${role}' updated.`); + (status) => { + if (status == QueryStatus.NO_DATA || status == QueryStatus.COMPLETE) { + onSuccess(); + } + }, + (records) => { + if (records && records[0] && records[0].error) { + onFail(records[0].error); } } ); + } else { + onSuccess(); } }, 1000); } + }, + (records) => { + if (records && records[0] && records[0].error) { + onFail(records[0].error); + } } ); } + }, + (records) => { + if (records && records[0] && records[0].error) { + onFail(records[0].error); + } } ); -}; +} /** * Generic query builder for adding/removing grants/denies for a list of labels. @@ -120,6 +138,8 @@ export const retrieveAllowAndDenyLists = ( setLabels, setAllowList, setDenyList, + setFixedAllowList, + setFixedDenyList, setLoaded ) => { runCypherQuery( @@ -131,7 +151,7 @@ export const retrieveAllowAndDenyLists = ( AND role = $rolename AND action = 'match' AND segment STARTS WITH 'NODE(' - RETURN access, collect(substring(segment, 5, size(segment)-6)) as nodes`, + RETURN access, collect(substring(segment, 5, size(segment)-6)) as nodes, graph = "*" as fixed`, { rolename: currentRole, database: database }, 1000, (status) => { @@ -142,12 +162,21 @@ export const retrieveAllowAndDenyLists = ( }, (records) => { // Extract granted and denied label list from the result of the SHOW PRIVILEGES query - const grants = records.filter((r) => r._fields[0] == 'GRANTED'); - const denies = records.filter((r) => r._fields[0] == 'DENIED'); + const grants = records.filter((r) => r._fields[0] == 'GRANTED' && r._fields[2] == false); + const denies = records.filter((r) => r._fields[0] == 'DENIED' && r._fields[2] == false); const grantedLabels = grants[0] ? [...new Set(grants[0]._fields[1])] : []; const deniedLabels = denies[0] ? [...new Set(denies[0]._fields[1])] : []; - setAllowList(grantedLabels); - setDenyList(deniedLabels); + + // Do the same for fixed grants (those stored under the '*' graph permission) + const fixedGrants = records.filter((r) => r._fields[0] == 'GRANTED' && r._fields[2] == true); + const fixedDenies = records.filter((r) => r._fields[0] == 'DENIED' && r._fields[2] == true); + const fixedGrantedLabels = fixedGrants[0] ? [...new Set(fixedGrants[0]._fields[1])] : []; + const fixedDeniedLabels = fixedDenies[0] ? [...new Set(fixedDenies[0]._fields[1])] : []; + + setAllowList([...new Set(grantedLabels.concat(fixedGrantedLabels))]); + setDenyList([...new Set(deniedLabels.concat(fixedDeniedLabels))]); + setFixedAllowList(fixedGrantedLabels); + setFixedDenyList(fixedDeniedLabels); // Here we build a set of all POSSIBLE labels, that includes the list in the database, plus those in denies and grants. const possibleLabels = [...new Set(allLabels.concat(grantedLabels).concat(deniedLabels))]; @@ -226,8 +255,9 @@ export function retrieveDatabaseList(driver, setDatabases: React.Dispatch { +export async function updateUsers(driver, currentRole, allUsers, selectedUsers, onSuccess, onFail) { // 1. Build the query that removes all users from the role. + let globalStatus = -1; await runCypherQuery( driver, 'system', @@ -235,16 +265,32 @@ export const updateUsers = async (driver, currentRole, allUsers, selectedUsers) {}, 1000, (status) => { - if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) { - // TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query. - // We build in an artificial delay... - const timeout = setTimeout(() => { - // 2. Re-assign only selected users to the role. - if (selectedUsers.length > 0) { - runCypherQuery(driver, 'system', `GRANT ROLE ${currentRole} TO ${selectedUsers.join(',')}`); - } - }, 1000); + globalStatus = status; + }, + (records) => { + if (records && records[0] && records[0].error) { + onFail(records[0].error); } } ); -}; + if (globalStatus == QueryStatus.NO_DATA || globalStatus == QueryStatus.COMPLETE) { + // TODO: Neo4j is very slow in updating after the previous query, even though it is technically a finished query. + // We build in an artificial delay... + if (selectedUsers.length > 0) { + await runCypherQuery( + driver, + 'system', + `GRANT ROLE ${currentRole} TO ${selectedUsers.join(',')}`, + {}, + 1000, + (status) => { + if (status == QueryStatus.NO_DATA || QueryStatus.COMPLETE) { + onSuccess(); + } + } + ); + } else { + onSuccess(); + } + } +} diff --git a/src/modal/AboutModal.tsx b/src/modal/AboutModal.tsx index 1e40e9dab..d3e8d663f 100644 --- a/src/modal/AboutModal.tsx +++ b/src/modal/AboutModal.tsx @@ -3,7 +3,7 @@ import { Button, Dialog, TextLink } from '@neo4j-ndl/react'; import { BookOpenIconOutline, BeakerIconOutline } from '@neo4j-ndl/react/icons'; import { Section, SectionTitle, SectionContent } from './ModalUtils'; -export const version = '2.4.3'; +export const version = '2.4.4'; export const NeoAboutModal = ({ open, handleClose, getDebugState }) => { const downloadDebugFile = () => { diff --git a/src/modal/ExportModal.tsx b/src/modal/ExportModal.tsx index 7040ede4a..bb1d76fe0 100644 --- a/src/modal/ExportModal.tsx +++ b/src/modal/ExportModal.tsx @@ -14,7 +14,7 @@ export const NeoExportModal = ({ dashboard }) => { return ( <> - setOpen(true)} aria-label='Export' title='Export'> + setOpen(true)} aria-label='Export'> diff --git a/src/report/ReportQueryRunner.ts b/src/report/ReportQueryRunner.ts index 4259b66e6..599e4afde 100644 --- a/src/report/ReportQueryRunner.ts +++ b/src/report/ReportQueryRunner.ts @@ -65,6 +65,7 @@ export async function runCypherQuery( setStatus(QueryStatus.ERROR); return; } + const session = database ? driver.session({ database: database }) : driver.session(); const transaction = session.beginTransaction({ timeout: queryTimeLimit * 1000, connectionTimeout: 2000 }); diff --git a/yarn.lock b/yarn.lock index c5dcfb1f6..d9935f9e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10435,15 +10435,6 @@ neo4j-client-sso@^1.2.2: jwt-decode "^3.1.2" lodash.pick "^4.4.0" -neo4j-driver-bolt-connection@5.12.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-5.12.0.tgz#aff161367d287579d7bdd3ee4179eed324398210" - integrity sha512-dlYbFsfT0HopGItitG5uDK4nAkcqSPNtRqMz318qy//7fb/7OXVLGYikj57Ve1toJiJD8IIVErt/dVuEUHVxGA== - dependencies: - buffer "^6.0.3" - neo4j-driver-core "5.12.0" - string_decoder "^1.3.0" - neo4j-driver-bolt-connection@^4.4.10: version "4.4.10" resolved "https://registry.yarnpkg.com/neo4j-driver-bolt-connection/-/neo4j-driver-bolt-connection-4.4.10.tgz#a8b5b7f82b1d6f9a71a43eafcb0e21512ea24908" @@ -10453,11 +10444,6 @@ neo4j-driver-bolt-connection@^4.4.10: neo4j-driver-core "^4.4.10" string_decoder "^1.3.0" -neo4j-driver-core@5.12.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-5.12.0.tgz#1f8616da7e945921574811368a68f5d2501bfd35" - integrity sha512-xBRi5oezysDUvtvBiIgBchzumkDZxvR9ol9sUtA9PBgVENeSmPH3CncitY8S979CFELS6wH7kydcjPLB4QMOzA== - neo4j-driver-core@^4.4.10: version "4.4.10" resolved "https://registry.yarnpkg.com/neo4j-driver-core/-/neo4j-driver-core-4.4.10.tgz#6f4c1ccc1199f864b149bdcef5e50e45ff95c29e" @@ -10473,15 +10459,6 @@ neo4j-driver@^4.4.5: neo4j-driver-core "^4.4.10" rxjs "^6.6.3" -neo4j-driver@^5.12.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/neo4j-driver/-/neo4j-driver-5.12.0.tgz#1b2d7db1672ad224f0146542efee306a0a156a11" - integrity sha512-T2Vz63XDkL9TomM16dBusuXbo7d9SIGw2g3VR/rmrWTdbl1V1LYFx/u1P7AwBsFuX08oncKHfZwHGsWrCvdMyA== - dependencies: - neo4j-driver-bolt-connection "5.12.0" - neo4j-driver-core "5.12.0" - rxjs "^7.8.1" - next-tick@1, next-tick@^1.0.0, next-tick@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -12311,13 +12288,6 @@ rxjs@^7.5.1, rxjs@^7.5.5, rxjs@^7.8.0: dependencies: tslib "^2.1.0" -rxjs@^7.8.1: - version "7.8.1" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" - integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== - dependencies: - tslib "^2.1.0" - sade@^1.7.3: version "1.8.1" resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701"