From 87a7ce239f7e03f6c425050fb87b57a2c29c6d04 Mon Sep 17 00:00:00 2001 From: Suren Date: Wed, 29 Oct 2025 17:27:48 +0530 Subject: [PATCH 01/94] Implement dynamic request configurations --- .../developer-guide/integrations/geoserver.md | 29 +- docs/developer-guide/local-config.md | 100 +++- web/client/actions/security.js | 39 +- web/client/api/ArcGIS.js | 4 +- web/client/api/CSW.js | 8 +- web/client/api/TMS.js | 4 +- web/client/api/ThreeDTiles.js | 4 +- web/client/api/WFS.js | 4 +- web/client/api/WMS.js | 11 +- web/client/api/WMTS.js | 7 +- web/client/api/catalog/TMS_1_0_0.js | 5 +- .../map/cesium/plugins/ArcGISLayer.js | 3 +- .../map/cesium/plugins/GraticuleLayer.js | 3 +- .../map/cesium/plugins/MarkerLayer.js | 4 +- .../map/cesium/plugins/ModelLayer.js | 2 +- .../map/cesium/plugins/OverlayLayer.js | 2 +- .../map/cesium/plugins/TerrainLayer.js | 4 +- .../map/cesium/plugins/ThreeDTilesLayer.js | 2 +- .../map/cesium/plugins/VectorLayer.js | 2 +- .../map/cesium/plugins/WMTSLayer.js | 16 +- .../map/leaflet/plugins/ElevationLayer.js | 4 +- .../map/leaflet/plugins/VectorLayer.jsx | 4 +- .../map/leaflet/plugins/WFSLayer.jsx | 2 +- .../map/leaflet/plugins/WMSLayer.js | 18 +- .../map/leaflet/plugins/WMTSLayer.js | 10 +- .../map/openlayers/plugins/ArcGISLayer.js | 6 +- .../map/openlayers/plugins/COGLayer.js | 15 +- .../map/openlayers/plugins/ElevationLayer.js | 4 +- .../map/openlayers/plugins/TMSLayer.js | 7 +- .../openlayers/plugins/TileProviderLayer.js | 6 +- .../map/openlayers/plugins/WMSLayer.js | 5 +- .../map/openlayers/plugins/WMTSLayer.js | 14 +- web/client/components/misc/SecureImage.jsx | 53 +- web/client/configs/localConfig.json | 17 +- web/client/epics/security.js | 95 +++- web/client/epics/wfsquery.js | 4 +- web/client/libs/ajax.js | 79 ++- web/client/observables/wfs.js | 17 +- web/client/observables/wms.js | 10 +- web/client/observables/wps/execute.js | 9 +- .../components/StyleBasedWMSJsonLegend.jsx | 5 +- web/client/reducers/security.js | 34 +- web/client/selectors/catalog.js | 12 +- web/client/selectors/security.js | 3 +- web/client/utils/LayersUtils.js | 7 +- web/client/utils/SecurityUtils.js | 314 +++++++---- .../utils/__tests__/SecurityUtils-test.js | 508 +++++++++++++++++- web/client/utils/cesium/WMSUtils.js | 6 +- web/client/utils/mapinfo/wfs.js | 13 +- web/client/utils/mapinfo/wms.js | 5 +- web/client/utils/mapinfo/wmts.js | 5 +- 51 files changed, 1148 insertions(+), 396 deletions(-) diff --git a/docs/developer-guide/integrations/geoserver.md b/docs/developer-guide/integrations/geoserver.md index 68cf8a9215a..2e9cfb5f053 100644 --- a/docs/developer-guide/integrations/geoserver.md +++ b/docs/developer-guide/integrations/geoserver.md @@ -158,25 +158,30 @@ The last step is to configure MapStore to use the authkey with the configured in ```javascript //... -"useAuthenticationRules": true, - "authenticationRules": [{ +"requestsConfigurationRules": [ + { "urlPattern": ".*geostore.*", - "method": "bearer" - }, { + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { "urlPattern": "\\/geoserver/.*", - "authkeyParamName": "authkey", - "method": "authkey" - }], + "params": { + "authkey": "${securityToken}" + } + } +], //... ``` -- Verify that "useAuthenticationRules" is set to `true` -- `authenticationRules` array should contain 2 rules: - - The first rule should already be present, and defines the authentication method used internally in mapstore +- Note: The new `requestsConfigurationRules` system is always active when rules are present, no flag needed +- `requestsConfigurationRules` array should contain 2 rules: + - The first rule should already be present, and defines the authentication method used internally in mapstore (Bearer token) - The second rule (the one you need to add) should be added and defines how to authenticate to GeoServer: - `urlPattern`: is a regular expression that identifies the request url where to apply the rule - - `method`: set it to `authkey` to use the authentication filter you just created in Geoserver. - - `authkeyParamName`: is the name of the authkey parameter defined in GeoServer (set to `authkey` by default) + - `params`: use query parameters for authkey authentication + - `authkey`: the name of the parameter (must match the one in GeoServer configuration, default is `authkey`) ### Advantages of user integration diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 46a7f4a7df3..8a95038d673 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -44,17 +44,30 @@ This is the main structure: // path to the translation files directory (if different from default) "translationsPath", // if true, every ajax and mapping request will be authenticated with the configurations if match a rule (default: true) - "useAuthenticationRules": true - // the authentication rules to match - "authenticationRules": [ - { // every rule has a `urlPattern` regex to match - "urlPattern": ".*geostore.*", - // and a authentication `method` to use (basic, authkey, browserWithCredentials, header) - "method": "basic" - }, { - "urlPattern": "\\/geoserver.*", - "method": "authkey" - }], + // the request configuration rules to match + "requestsConfigurationRules": [ + { // every rule has a `urlPattern` regex to match + "urlPattern": ".*geostore.*", + // headers to add to matching requests + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, { + "urlPattern": "\\/geoserver/.*", + // parameters to add to matching requests + "params": { + "authkey": "${securityToken}" + } + }, { + "urlPattern": ".*azure-blob.*", + // expiration timestamp (optional, Unix timestamp in seconds) + "expires": 1735689600, + // parameters can be used for SAS tokens + "params": { + "sv": "2024-11-04", + "sig": "${sasToken}" + } + }], // flag for postponing mapstore 2 load time after theme "loadAfterTheme": false, // if defined, WMS layer styles localization will be added @@ -149,23 +162,60 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta - `initialState`: is an object that will initialize the state with some default values and this WILL OVERRIDE the initialState imposed by plugins & reducers. - `projectionDefs`: is an array of objects that contain definitions for Coordinate Reference Systems - `gridFiles`: is an object that contains definitions for grid files used in coordinate transformations -- `useAuthenticationRules`: if this flag is set to true, the `authenticationRules` will be used to authenticate every ajax and mapping request. If the flag is set to false, the `authenticationRules` will be ignored. -- `authenticationRules`: is an array of objects that contain rules to match for authentication. Each rule has a `urlPattern` regex to match and a `method` to use (`basic`, `authkey`, `header`, `browserWithCredentials`). If the URL of a request matches the `urlPattern` of a rule, the `method` will be used to authenticate the request. The `method` can be: - - `basic` will use the basic authentication method getting the credentials from the user that logged in (adding the header `Authorization` `Basic ` to the request). ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - `authkey` will use the authkey method getting the credentials from the user that logged in. The token of the current MapStore session will be used as the authkey value, so this works only with the geoserver integration. - - `bearer` will use the header `Authorization` `Bearer ` getting the credentials from the user that logged in. The token of the current MapStore session will be used as the bearer value, so this works only with the geoserver integration. - - `header` will use the header method getting the credentials from the user that logged in. You can add an `headers` object containing the static headers to this rule to specify witch headers to use. e.g. - - `browserWithCredentials` will add the `withCredentials` parameter to ajax requests, so the browser will send the cookies and the authentication headers to the server. This method is useful when you have a proxy that needs to authenticate the user. ***Note**: this method is not implemented for image tile requests (e.g. layers) but only for ajax requests.* - - ```json +- `useAuthenticationRules` (deprecated): if this flag is set to true, legacy `authenticationRules` will be used. The new `requestsConfigurationRules` system does not require this flag and is always active when rules are present. +- `requestsConfigurationRules`: is an array of objects that contain rules to match for request configuration. Each rule has a `urlPattern` regex to match and either `headers`, `params`, or `withCredentials` configuration. If the URL of a request matches the `urlPattern` of a rule, the configuration will be applied to the request. + + **Available variables for template substitution (ES6 template syntax `${variable}`):** + - `${securityToken}` - The current MapStore session token (automatically replaced) + - `${authHeader}` - The basic authentication header (automatically replaced) + + **Configuration options:** + - `headers` - Object containing HTTP headers to add to matching requests. Example: + + ```json { - "urlPattern": ".*geostore.*", - "method": "header", - "headers": { - "X-Auth-Token": "mytoken" - } + "urlPattern": ".*geostore.*", + "headers": { + "Authorization": "Bearer ${securityToken}" + } } ``` + + - `params` - Object containing query parameters to add to matching requests. Example: + + ```json + { + "urlPattern": "\\/geoserver/.*", + "params": { + "authkey": "${securityToken}" + } + } + ``` + + - `withCredentials` - Boolean to enable sending credentials with requests (useful with proxies): + + ```json + { + "urlPattern": ".*internal-api.*", + "withCredentials": true + } + ``` + + - `expires` - Optional Unix timestamp (in seconds) for automatic rule expiration. Example: + + ```json + { + "urlPattern": ".*azure-blob.*", + "expires": 1735689600, + "params": { + "sv": "2024-11-04", + "sig": "token" + } + } + ``` + +!!! note "Backward Compatibility" + The old `useAuthenticationRules` and `authenticationRules` configuration still works and will be automatically converted to the new format. However, the new format is recommended for better flexibility and features like expiration support. ### initialState configuration diff --git a/web/client/actions/security.js b/web/client/actions/security.js index 25522cef465..1c696350d70 100644 --- a/web/client/actions/security.js +++ b/web/client/actions/security.js @@ -14,7 +14,6 @@ import AuthenticationAPI from '../api/GeoStoreDAO'; import {setCredentials, getToken, getRefreshToken} from '../utils/SecurityUtils'; import {encodeUTF8} from '../utils/EncodeUtils'; - export const CHECK_LOGGED_USER = 'CHECK_LOGGED_USER'; export const LOGIN_SUBMIT = 'LOGIN_SUBMIT'; export const LOGIN_PROMPT_CLOSED = "LOGIN:LOGIN_PROMPT_CLOSED"; @@ -34,6 +33,11 @@ export const SET_CREDENTIALS = 'SECURITY:SET_CREDENTIALS'; export const CLEAR_SECURITY = 'SECURITY:CLEAR_SECURITY'; export const SET_PROTECTED_SERVICES = 'SECURITY:SET_PROTECTED_SERVICES'; export const REFRESH_SECURITY_LAYERS = 'SECURITY:REFRESH_SECURITY_LAYERS'; + +export const UPDATE_REQUESTS_RULES = 'SECURITY:UPDATE_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES = 'SECURITY:LOAD_REQUESTS_RULES'; +export const LOAD_REQUESTS_RULES_ERROR = 'SECURITY:LOAD_REQUESTS_RULES_ERROR'; + export function loginSuccess(userDetails, username, password, authProvider) { return { type: LOGIN_SUCCESS, @@ -229,3 +233,36 @@ export function refreshSecurityLayers() { type: REFRESH_SECURITY_LAYERS }; } + +/** + * Updates the request configuration rules + * @param {Array} rules - Array of request configuration rules + * @param {boolean} enabled - Whether request configuration is enabled + */ +export const updateRequestsRules = (rules) => { + return { + type: UPDATE_REQUESTS_RULES, + rules + }; +}; + +/** + * Starts loading request configuration rules + */ +export const loadRequestsRules = (rules) => { + return { + type: LOAD_REQUESTS_RULES, + rules + }; +}; + +/** + * Error loading request configuration rules + * @param {Error} error - The error that occurred + */ +export const loadRequestsRulesError = (error) => { + return { + type: LOAD_REQUESTS_RULES_ERROR, + error + }; +}; diff --git a/web/client/api/ArcGIS.js b/web/client/api/ArcGIS.js index 5f0a5b042a4..ff3e994863a 100644 --- a/web/client/api/ArcGIS.js +++ b/web/client/api/ArcGIS.js @@ -6,7 +6,6 @@ * LICENSE file in the root directory of this source tree. */ -import { getAuthorizationBasic } from '../utils/SecurityUtils'; import axios from '../libs/ajax'; import { reprojectBbox } from '../utils/CoordinatesUtils'; import trimEnd from 'lodash/trimEnd'; @@ -89,14 +88,13 @@ export const searchAndPaginate = (records, params) => { }; const getData = (url, params = {}) => { const protectedId = params?.info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); const request = _cache[url] ? () => Promise.resolve(_cache[url]) : () => axios.get(url, { params: { f: 'json' }, - headers + _msAuthSourceId: protectedId }).then(({ data }) => { _cache[url] = data; return data; diff --git a/web/client/api/CSW.js b/web/client/api/CSW.js index 9613fe29cf2..bc4b3859a4d 100644 --- a/web/client/api/CSW.js +++ b/web/client/api/CSW.js @@ -16,7 +16,6 @@ import { extractCrsFromURN, makeBboxFromOWS, makeNumericEPSG, getExtentFromNorma import WMS from "../api/WMS"; import { THREE_D_TILES, getCapabilities } from './ThreeDTiles'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -513,12 +512,11 @@ const Api = { getRecords: function(url, startPosition, maxRecords, text, options) { const body = constructXMLBody(startPosition, maxRecords, text, options); const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); return axios.post(parseUrl(url), body, { headers: { - 'Content-Type': 'application/xml', - ...headers - } + 'Content-Type': 'application/xml' + }, + _msAuthSourceId: protectedId }).then((response) => { const { error, _dcRef, result } = parseCSWResponse(response) || {}; if (result) { diff --git a/web/client/api/TMS.js b/web/client/api/TMS.js index f80c5948038..9d71c4ee9c3 100644 --- a/web/client/api/TMS.js +++ b/web/client/api/TMS.js @@ -7,7 +7,6 @@ */ import xml2js from 'xml2js'; import axios from '../libs/ajax'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; /** * Common requests to TMS services. @@ -21,8 +20,7 @@ import { getAuthorizationBasic } from '../utils/SecurityUtils'; */ export const getTileMap = (url, options) => { const protectedId = options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(response => { return new Promise((resolve) => { xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => resolve(result)); diff --git a/web/client/api/ThreeDTiles.js b/web/client/api/ThreeDTiles.js index a0b5ffc1830..467d5f26169 100644 --- a/web/client/api/ThreeDTiles.js +++ b/web/client/api/ThreeDTiles.js @@ -10,7 +10,6 @@ import axios from '../libs/ajax'; import { convertRadianToDegrees } from '../utils/CoordinatesUtils'; import { METERS_PER_UNIT } from '../utils/MapUtils'; import { logError } from '../utils/DebugUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; // converts the boundingVolume of the root tileset to a valid layer bbox function tilesetToBoundingBox(Cesium, tileset) { @@ -140,8 +139,7 @@ function extractCapabilities(tileset) { */ export const getCapabilities = (url, info) => { const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers}) + return axios.get(url, {_msAuthSourceId: protectedId}) .then(({ data }) => { return extractCapabilities(data).then((properties) => ({ tileset: data, ...properties })); }).catch((e) => { diff --git a/web/client/api/WFS.js b/web/client/api/WFS.js index 250d2a1aebc..6370be114cf 100644 --- a/web/client/api/WFS.js +++ b/web/client/api/WFS.js @@ -14,7 +14,6 @@ import {toOGCFilterParts} from '../utils/FilterUtils'; import { getDefaultUrl } from '../utils/URLUtils'; import { castArray } from 'lodash'; import { isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; @@ -140,8 +139,7 @@ export const getCapabilities = function(url, info) { return Promise.resolve(cached.data); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(getCapabilitiesURL(url, {headers})) + return axios.get(getCapabilitiesURL(url), {_msAuthSourceId: protectedId}) .then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false, stripPrefix: true }, (ignore, result) => { diff --git a/web/client/api/WMS.js b/web/client/api/WMS.js index b6b9def38a1..faf4998ac89 100644 --- a/web/client/api/WMS.js +++ b/web/client/api/WMS.js @@ -13,7 +13,6 @@ import axios from '../libs/ajax'; import { getConfigProp } from '../utils/ConfigUtils'; import { getWMSBoundingBox } from '../utils/CoordinatesUtils'; import { isValidGetMapFormat, isValidGetFeatureInfoFormat } from '../utils/WMSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const capabilitiesCache = {}; export const WMS_GET_CAPABILITIES_VERSION = '1.3.0'; @@ -160,12 +159,12 @@ export const getDimensions = (layer) => { * - `Capability`: capability object that contains layers and requests formats * - `Service`: service information object */ -export const getCapabilities = (url, headers = {}) => { +export const getCapabilities = (url, {headers, params, _msAuthSourceId} = {}) => { return axios.get(parseUrl(url, { service: "WMS", version: WMS_GET_CAPABILITIES_VERSION, request: "GetCapabilities" - }), {headers}).then((response) => { + }), {headers, params, _msAuthSourceId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -204,8 +203,7 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return getCapabilities(url, headers) + return getCapabilities(url, {_msAuthSourceId: protectedId}) .then((json) => { capabilitiesCache[url] = { timestamp: new Date().getTime(), @@ -215,13 +213,12 @@ export const getRecords = (url, startPosition, maxRecords, text, options) => { }); }; export const describeLayers = (url, layers, security) => { - const headers = getAuthorizationBasic(security?.sourceId); return axios.get(parseUrl(url, { service: "WMS", version: WMS_DESCRIBE_LAYER_VERSION, layers: layers, request: "DescribeLayer" - }), {headers}).then((response) => { + }), {_msAuthSourceId: security?.sourceId}).then((response) => { let descriptions; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { descriptions = result && result.WMS_DescribeLayerResponse && result.WMS_DescribeLayerResponse.LayerDescription; diff --git a/web/client/api/WMTS.js b/web/client/api/WMTS.js index a59c3d74f05..ce05b95ccd1 100644 --- a/web/client/api/WMTS.js +++ b/web/client/api/WMTS.js @@ -24,7 +24,6 @@ import { getDefaultStyleIdentifier, getDefaultFormat } from '../utils/WMTSUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; export const parseUrl = (url) => { const parsed = urlUtil.parse(getDefaultUrl(url), true); @@ -82,8 +81,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; @@ -106,8 +104,7 @@ const Api = { }); } const protectedId = options?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(parseUrl(url), {headers}).then((response) => { + return axios.get(parseUrl(url), {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, {explicitArray: false}, (ignore, result) => { json = result; diff --git a/web/client/api/catalog/TMS_1_0_0.js b/web/client/api/catalog/TMS_1_0_0.js index adb57e95ad2..e16fecf0d40 100644 --- a/web/client/api/catalog/TMS_1_0_0.js +++ b/web/client/api/catalog/TMS_1_0_0.js @@ -9,7 +9,7 @@ import ConfigUtils from '../../utils/ConfigUtils'; import xml2js from 'xml2js'; import axios from '../../libs/ajax'; import { get, castArray } from 'lodash'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../../utils/SecurityUtils'; import { guessFormat } from '../../utils/TMSUtils'; const capabilitiesCache = {}; @@ -55,8 +55,7 @@ export const getRecords = (url, startPosition, maxRecords, text, info) => { }); } const protectedId = info?.options?.service?.protectedId; - let headers = getAuthorizationBasic(protectedId); - return axios.get(url, {headers} ).then((response) => { + return axios.get(url, {_msAuthSourceId: protectedId}).then((response) => { let json; xml2js.parseString(response.data, { explicitArray: false }, (ignore, result) => { json = { ...result, url }; diff --git a/web/client/components/map/cesium/plugins/ArcGISLayer.js b/web/client/components/map/cesium/plugins/ArcGISLayer.js index ef1d4f8e8c7..5d4b78f3629 100644 --- a/web/client/components/map/cesium/plugins/ArcGISLayer.js +++ b/web/client/components/map/cesium/plugins/ArcGISLayer.js @@ -10,6 +10,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; import { isImageServerUrl } from '../../../../utils/ArcGISUtils'; import { getProxiedUrl } from '../../../../utils/ConfigUtils'; +import isEqual from 'lodash/isEqual'; // this override is needed to apply the selected format @@ -85,7 +86,7 @@ const create = (options) => { }; const update = (layer, newOptions, oldOptions) => { - if (newOptions.forceProxy !== oldOptions.forceProxy) { + if (newOptions.forceProxy !== oldOptions.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return create(newOptions); } return null; diff --git a/web/client/components/map/cesium/plugins/GraticuleLayer.js b/web/client/components/map/cesium/plugins/GraticuleLayer.js index b38a3025bcc..f05ab08b391 100644 --- a/web/client/components/map/cesium/plugins/GraticuleLayer.js +++ b/web/client/components/map/cesium/plugins/GraticuleLayer.js @@ -8,6 +8,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; +import isEqual from 'lodash/isEqual'; /** * Created by thomas on 27/01/14. @@ -341,7 +342,7 @@ const createLayer = (options, map) => { Layers.registerType('graticule', { create: createLayer, update: (layer, newOptions, oldOptions, map) => { - if (newOptions.visibility !== oldOptions.visibility) { + if (newOptions.visibility !== oldOptions.visibility || !isEqual(oldOptions.security, newOptions.security)) { layer.setVisible(false); // clear all previous labels and primitive if (newOptions.visibility) { return createLayer(newOptions, map); diff --git a/web/client/components/map/cesium/plugins/MarkerLayer.js b/web/client/components/map/cesium/plugins/MarkerLayer.js index 1847d39152d..831e36a80c6 100644 --- a/web/client/components/map/cesium/plugins/MarkerLayer.js +++ b/web/client/components/map/cesium/plugins/MarkerLayer.js @@ -9,7 +9,7 @@ import Layers from '../../../../utils/cesium/Layers'; import * as Cesium from 'cesium'; -import { isEqual } from 'lodash'; +import isEqual from 'lodash/isEqual'; /** * @deprecated @@ -45,7 +45,7 @@ Layers.registerType('marker', { }; }, update: function(layer, newOptions, oldOptions, map) { - if (!isEqual(newOptions.point, oldOptions.point)) { + if (!isEqual(newOptions.point, oldOptions.point) || !isEqual(oldOptions.security, newOptions.security)) { return this.create(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/ModelLayer.js b/web/client/components/map/cesium/plugins/ModelLayer.js index d9d578b2518..cd0ee004509 100644 --- a/web/client/components/map/cesium/plugins/ModelLayer.js +++ b/web/client/components/map/cesium/plugins/ModelLayer.js @@ -194,7 +194,7 @@ Layers.registerType('model', { if (primitives && !isEqual(newOptions?.features?.[0], oldOptions?.features?.[0])) { updatePrimitivesMatrix(primitives, newOptions?.features?.[0]); } - if (newOptions?.forceProxy !== oldOptions?.forceProxy) { + if (newOptions?.forceProxy !== oldOptions?.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/OverlayLayer.js b/web/client/components/map/cesium/plugins/OverlayLayer.js index 5fc793b88d9..bc2410a3e6d 100644 --- a/web/client/components/map/cesium/plugins/OverlayLayer.js +++ b/web/client/components/map/cesium/plugins/OverlayLayer.js @@ -198,7 +198,7 @@ Layers.registerType('overlay', { }; }, update: function(layer, newOptions, oldOptions, map) { - if (!isEqual(newOptions.position, oldOptions.position)) { + if (!isEqual(newOptions.position, oldOptions.position) || !isEqual(oldOptions.security, newOptions.security)) { return this.create(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/TerrainLayer.js b/web/client/components/map/cesium/plugins/TerrainLayer.js index b70d9928355..a62e9dd9e4f 100644 --- a/web/client/components/map/cesium/plugins/TerrainLayer.js +++ b/web/client/components/map/cesium/plugins/TerrainLayer.js @@ -11,6 +11,7 @@ import * as Cesium from 'cesium'; import GeoServerBILTerrainProvider from '../../../../utils/cesium/GeoServerBILTerrainProvider'; import WMSUtils from '../../../../utils/cesium/WMSUtils'; import { getProxyUrl } from "../../../../utils/ProxyUtils"; +import isEqual from 'lodash/isEqual'; function cesiumOptionsMapping(config) { return { @@ -115,7 +116,8 @@ const updateLayer = (layer, newOptions, oldOptions, map) => { || newOptions?.options?.crs !== oldOptions?.options?.crs || newOptions?.version !== oldOptions?.version || newOptions?.name !== oldOptions?.name - || oldOptions.forceProxy !== newOptions.forceProxy) { + || oldOptions.forceProxy !== newOptions.forceProxy + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } return null; diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js index 86b25787828..f727d5d919f 100644 --- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js +++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js @@ -194,7 +194,7 @@ const createLayer = (options, map) => { Layers.registerType('3dtiles', { create: createLayer, update: function(layer, newOptions, oldOptions, map) { - if (newOptions.forceProxy !== oldOptions.forceProxy) { + if (newOptions.forceProxy !== oldOptions.forceProxy || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } if ( diff --git a/web/client/components/map/cesium/plugins/VectorLayer.js b/web/client/components/map/cesium/plugins/VectorLayer.js index 86c2aba45f0..a2021714a76 100644 --- a/web/client/components/map/cesium/plugins/VectorLayer.js +++ b/web/client/components/map/cesium/plugins/VectorLayer.js @@ -60,7 +60,7 @@ const createLayer = (options, map) => { Layers.registerType('vector', { create: createLayer, update: (layer, newOptions, oldOptions, map) => { - if (!isEqual(newOptions.features, oldOptions.features)) { + if (!isEqual(newOptions.features, oldOptions.features) || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions, map); } if (layer?.styledFeatures && !isEqual(newOptions?.layerFilter, oldOptions?.layerFilter)) { diff --git a/web/client/components/map/cesium/plugins/WMTSLayer.js b/web/client/components/map/cesium/plugins/WMTSLayer.js index 01dbcc4060d..c69da8ff7d9 100644 --- a/web/client/components/map/cesium/plugins/WMTSLayer.js +++ b/web/client/components/map/cesium/plugins/WMTSLayer.js @@ -18,7 +18,7 @@ import { isEqual, isObject, isArray, slice, get, head} from 'lodash'; import urlParser from 'url'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function splitUrl(originalUrl) { let url = originalUrl; @@ -118,13 +118,13 @@ function wmtsToCesiumOptions(_options) { const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : ''; let headersOpts; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - headersOpts = { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - } - }; + if (options.security && options.url) { + const urlToCheck = isArray(options.url) ? options.url[0] : options.url; + const requestConfig = getRequestConfigurationByUrl(urlToCheck, null, options.security?.sourceId); + + if (requestConfig.headers) { + headersOpts = { headers: requestConfig.headers }; + } } return Object.assign({ // TODO: multi-domain support, if use {s} switches to RESTFul mode diff --git a/web/client/components/map/leaflet/plugins/ElevationLayer.js b/web/client/components/map/leaflet/plugins/ElevationLayer.js index ff5d6dfd3b2..d6d1772c482 100644 --- a/web/client/components/map/leaflet/plugins/ElevationLayer.js +++ b/web/client/components/map/leaflet/plugins/ElevationLayer.js @@ -73,8 +73,8 @@ L.tileLayer.elevationWMS = function(urls, options, nodata, littleEndian, id) { const createWMSElevationLayer = (options) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = L.tileLayer.elevationWMS( urls, { diff --git a/web/client/components/map/leaflet/plugins/VectorLayer.jsx b/web/client/components/map/leaflet/plugins/VectorLayer.jsx index 0ab86451add..4ecdfc640f5 100644 --- a/web/client/components/map/leaflet/plugins/VectorLayer.jsx +++ b/web/client/components/map/leaflet/plugins/VectorLayer.jsx @@ -84,7 +84,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { if (newOptions.opacity !== oldOptions.opacity) { layer.opacity = newOptions.opacity; } - if (!isEqual(newOptions.style, oldOptions.style)) { + if (!isEqual(newOptions.style, oldOptions.style) || !isEqual(oldOptions.security, newOptions.security)) { return isNewStyle(newOptions) ? createLayer(newOptions) : createLayerLegacy(newOptions); @@ -93,7 +93,7 @@ const updateLayerLegacy = (layer, newOptions, oldOptions) => { }; const updateLayer = (layer, newOptions, oldOptions) => { - if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter)) { + if (!isEqual(oldOptions.layerFilter, newOptions.layerFilter) || !isEqual(oldOptions.security, newOptions.security)) { layer.remove(); return createLayer(newOptions); } diff --git a/web/client/components/map/leaflet/plugins/WFSLayer.jsx b/web/client/components/map/leaflet/plugins/WFSLayer.jsx index cb013240610..15d4491e157 100644 --- a/web/client/components/map/leaflet/plugins/WFSLayer.jsx +++ b/web/client/components/map/leaflet/plugins/WFSLayer.jsx @@ -88,7 +88,7 @@ Layers.registerType('wfs', { return layer; }, update: (layer, newOptions, oldOptions) => { - if (needsReload(oldOptions, newOptions)) { + if (needsReload(oldOptions, newOptions) || !isEqual(oldOptions.security, newOptions.security)) { loadFeatures(layer, newOptions); } if (!isEqual(newOptions.style, oldOptions.style) diff --git a/web/client/components/map/leaflet/plugins/WMSLayer.js b/web/client/components/map/leaflet/plugins/WMSLayer.js index 1cb724eccfc..a4b2a39f760 100644 --- a/web/client/components/map/leaflet/plugins/WMSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMSLayer.js @@ -10,7 +10,8 @@ import Layers from '../../../../utils/leaflet/Layers'; import { filterWMSParamOptions, getWMSURLs, wmsToLeafletOptions, removeNulls } from '../../../../utils/leaflet/WMSUtils'; import L from 'leaflet'; -import { isArray } from 'lodash'; +import isEqual from 'lodash/isEqual'; +import isArray from 'lodash/isArray'; import {addAuthenticationToSLD, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import 'leaflet.nontiledlayer'; @@ -56,19 +57,24 @@ Layers.registerType('wms', { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = removeNulls(wmsToLeafletOptions(options) || {}); + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); if (options.singleTile) { return L.nonTiledLayer.wmsCustom(urls[0], queryParameters); } return L.tileLayer.multipleUrlWMS(urls, queryParameters); }, update: function(layer, newOptions, oldOptions) { - if (oldOptions.singleTile !== newOptions.singleTile || oldOptions.tileSize !== newOptions.tileSize || oldOptions.securityToken !== newOptions.securityToken && newOptions.visibility) { + if ( + (oldOptions.singleTile !== newOptions.singleTile + || oldOptions.tileSize !== newOptions.tileSize + || oldOptions.securityToken !== newOptions.securityToken + || !isEqual(oldOptions.security, newOptions.security)) + && newOptions.visibility) { let newLayer; const urls = getWMSURLs(isArray(newOptions.url) ? newOptions.url : [newOptions.url]); - const queryParameters = wmsToLeafletOptions(newOptions) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, newOptions.securityToken)); + let queryParameters = wmsToLeafletOptions(newOptions) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, newOptions.securityToken, newOptions.security?.sourceId); if (newOptions.singleTile) { // return the nonTiledLayer newLayer = L.nonTiledLayer.wmsCustom(urls[0], queryParameters); diff --git a/web/client/components/map/leaflet/plugins/WMTSLayer.js b/web/client/components/map/leaflet/plugins/WMTSLayer.js index 9d59d7f0a32..f3250cedc3b 100644 --- a/web/client/components/map/leaflet/plugins/WMTSLayer.js +++ b/web/client/components/map/leaflet/plugins/WMTSLayer.js @@ -13,7 +13,8 @@ import {addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import { creditsToAttribution } from '../../../../utils/LayersUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import WMTS from '../../../../utils/leaflet/WMTS'; -import { isArray } from 'lodash'; +import isArray from 'lodash/isArray'; +import isEqual from 'lodash/isEqual'; import { isVectorFormat } from '../../../../utils/VectorTileUtils'; L.tileLayer.wmts = function(urls, options, matrixOptions) { @@ -47,8 +48,8 @@ function getWMSURLs(urls) { const createLayer = _options => { const options = WMTSUtils.parseTileMatrixSetOption(_options); const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmtsToLeafletOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmtsToLeafletOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const srs = normalizeSRS(options.srs || 'EPSG:3857', options.allowedSRS); const { tileMatrixSet, matrixIds } = WMTSUtils.getTileMatrix(options, srs); return L.tileLayer.wmts(urls, queryParameters, { @@ -65,7 +66,8 @@ const createLayer = _options => { const updateLayer = (layer, newOptions, oldOptions) => { if (oldOptions.securityToken !== newOptions.securityToken || oldOptions.format !== newOptions.format - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions); } return null; diff --git a/web/client/components/map/openlayers/plugins/ArcGISLayer.js b/web/client/components/map/openlayers/plugins/ArcGISLayer.js index 5bef918b143..714a6f63cf0 100644 --- a/web/client/components/map/openlayers/plugins/ArcGISLayer.js +++ b/web/client/components/map/openlayers/plugins/ArcGISLayer.js @@ -11,15 +11,11 @@ import { registerType } from '../../../../utils/openlayers/Layers'; import TileLayer from 'ol/layer/Tile'; import TileArcGISRest from 'ol/source/TileArcGISRest'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); diff --git a/web/client/components/map/openlayers/plugins/COGLayer.js b/web/client/components/map/openlayers/plugins/COGLayer.js index 103e622a687..cc54ab276e6 100644 --- a/web/client/components/map/openlayers/plugins/COGLayer.js +++ b/web/client/components/map/openlayers/plugins/COGLayer.js @@ -13,15 +13,16 @@ import get from 'lodash/get'; import GeoTIFF from 'ol/source/GeoTIFF.js'; import TileLayer from 'ol/layer/WebGLTile.js'; import { isProjectionAvailable } from '../../../../utils/ProjectionUtils'; -import { getCredentials } from '../../../../utils/SecurityUtils'; +import { getRequestConfigurationByUrl } from '../../../../utils/SecurityUtils'; function create(options) { - let sourceOptions; - if (options.security) { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; - sourceOptions.headers = { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }; + let sourceOptions = {}; + if (options.security && options.sources && options.sources.length > 0) { + const firstSource = options.sources[0]; + const requestConfig = getRequestConfigurationByUrl(firstSource.url, null, options.security?.sourceId); + if (requestConfig.headers) { + sourceOptions.headers = requestConfig.headers; + } } return new TileLayer({ msId: options.id, diff --git a/web/client/components/map/openlayers/plugins/ElevationLayer.js b/web/client/components/map/openlayers/plugins/ElevationLayer.js index bb1f0167333..139dbaa873f 100644 --- a/web/client/components/map/openlayers/plugins/ElevationLayer.js +++ b/web/client/components/map/openlayers/plugins/ElevationLayer.js @@ -57,8 +57,8 @@ function getElevation(pos) { const createWMSElevationLayer = (options, map) => { const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const layer = new TileLayer({ msId: options.id, opacity: options.opacity !== undefined ? options.opacity : 1, diff --git a/web/client/components/map/openlayers/plugins/TMSLayer.js b/web/client/components/map/openlayers/plugins/TMSLayer.js index efe24b5e1ef..e745cc7d2d8 100644 --- a/web/client/components/map/openlayers/plugins/TMSLayer.js +++ b/web/client/components/map/openlayers/plugins/TMSLayer.js @@ -12,14 +12,9 @@ import TileGrid from 'ol/tilegrid/TileGrid'; import TileLayer from 'ol/layer/Tile'; import Layers from '../../../../utils/openlayers/Layers'; -import { getCredentials } from '../../../../utils/SecurityUtils'; - const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); diff --git a/web/client/components/map/openlayers/plugins/TileProviderLayer.js b/web/client/components/map/openlayers/plugins/TileProviderLayer.js index 34d6c4bda64..c6df813737a 100644 --- a/web/client/components/map/openlayers/plugins/TileProviderLayer.js +++ b/web/client/components/map/openlayers/plugins/TileProviderLayer.js @@ -12,7 +12,6 @@ import { getUrls, template } from '../../../../utils/TileProviderUtils'; import XYZ from 'ol/source/XYZ'; import TileLayer from 'ol/layer/Tile'; import axios from 'axios'; -import { getCredentials } from '../../../../utils/SecurityUtils'; import { isEqual } from 'lodash'; function lBoundsToOlExtent(bounds, destPrj) { var [ [ miny, minx], [ maxy, maxx ] ] = bounds; @@ -20,11 +19,8 @@ function lBoundsToOlExtent(bounds, destPrj) { } const tileLoadFunction = options => (image, src) => { - const storedProtectedService = getCredentials(options.security?.sourceId) || {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { image.getImage().src = URL.createObjectURL(response.data); diff --git a/web/client/components/map/openlayers/plugins/WMSLayer.js b/web/client/components/map/openlayers/plugins/WMSLayer.js index 554d314dc46..ddd1851280a 100644 --- a/web/client/components/map/openlayers/plugins/WMSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMSLayer.js @@ -104,8 +104,8 @@ const createLayer = (options, map, mapId) => { }, map, mapId); } const urls = getWMSURLs(isArray(options.url) ? options.url : [options.url]); - const queryParameters = wmsToOpenlayersOptions(options) || {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = wmsToOpenlayersOptions(options) || {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const headers = getAuthenticationHeaders(urls[0], options.securityToken, options.security); const vectorFormat = isVectorFormat(options.format); @@ -187,6 +187,7 @@ const mustCreateNewLayer = (oldOptions, newOptions) => { || oldOptions.forceProxy !== newOptions.forceProxy || oldOptions.tileGridStrategy !== newOptions.tileGridStrategy || !isEqual(oldOptions.tileGrids, newOptions.tileGrids) + || !isEqual(oldOptions.security, newOptions.security) ); }; diff --git a/web/client/components/map/openlayers/plugins/WMTSLayer.js b/web/client/components/map/openlayers/plugins/WMTSLayer.js index a2e28542896..1c48dda7ae6 100644 --- a/web/client/components/map/openlayers/plugins/WMTSLayer.js +++ b/web/client/components/map/openlayers/plugins/WMTSLayer.js @@ -13,7 +13,7 @@ import head from 'lodash/head'; import last from 'lodash/last'; import axios from '../../../../libs/ajax'; import { proxySource } from '../../../../utils/openlayers/WMSUtils'; -import {getCredentials, addAuthenticationParameter} from '../../../../utils/SecurityUtils'; +import {addAuthenticationParameter} from '../../../../utils/SecurityUtils'; import * as WMTSUtils from '../../../../utils/WMTSUtils'; import CoordinatesUtils from '../../../../utils/CoordinatesUtils'; import MapUtils from '../../../../utils/MapUtils'; @@ -46,11 +46,8 @@ function getWMSURLs(urls, requestEncoding) { } const tileLoadFunction = (options) => (image, src) => { - const storedProtectedService = options.security ? getCredentials(options.security?.sourceId) : {}; axios.get(src, { - headers: { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }, + _msAuthSourceId: options.security?.sourceId, responseType: 'blob' }).then(response => { if (isValidResponse(response)) { @@ -119,8 +116,8 @@ const createLayer = options => { // the extent has effect to the tile ranges // we should skip the extent if the layer does not provide bounding box let extent = layerExtent && getIntersection(layerExtent, projection.getExtent()); - const queryParameters = {}; - urls.forEach(url => addAuthenticationParameter(url, queryParameters, options.securityToken)); + let queryParameters = {}; + queryParameters = addAuthenticationParameter(urls[0] || '', queryParameters, options.securityToken, options.security?.sourceId); const queryParametersString = urlParser.format({ query: { ...queryParameters } }); // TODO: support tileSizes from matrix @@ -189,7 +186,8 @@ const updateLayer = (layer, newOptions, oldOptions) => { || oldOptions.srs !== newOptions.srs || oldOptions.format !== newOptions.format || oldOptions.style !== newOptions.style - || oldOptions.credits !== newOptions.credits) { + || oldOptions.credits !== newOptions.credits + || !isEqual(oldOptions.security, newOptions.security)) { return createLayer(newOptions); } if (oldOptions.minResolution !== newOptions.minResolution) { diff --git a/web/client/components/misc/SecureImage.jsx b/web/client/components/misc/SecureImage.jsx index a87ac6c964b..858f0c8fa08 100644 --- a/web/client/components/misc/SecureImage.jsx +++ b/web/client/components/misc/SecureImage.jsx @@ -7,10 +7,7 @@ */ import React, { useEffect, useState } from 'react'; -import axios from 'axios'; - -import { getAuthKeyParameter, getAuthenticationMethod, getAuthorizationBasic, getToken } from '../../utils/SecurityUtils'; -import { updateUrlParams } from '../../utils/URLUtils'; +import axios from '../../libs/ajax'; const SecureImage = ({ @@ -29,45 +26,17 @@ const SecureImage = ({ } }; useEffect(() => { - const authMethod = getAuthenticationMethod(src); - - if (authMethod === "bearer") { - axios.get(src, { - responseType: 'blob' - }) - .then((response) => { - const imageUrl = URL.createObjectURL(response.data); - setImageSrc(imageUrl); - }) - .catch((error) => { - console.error('Error fetching image:', error); - }); - } else if (authMethod === "authkey") { - const authParam = getAuthKeyParameter(src); - const token = getToken(); - if (authParam && token) { - const newSrc = updateUrlParams(src, {[authParam]: token}); - setImageSrc(newSrc); - } else { - setImageSrc(src); - } - - } else if (props?.layer?.security?.sourceId) { - const headers = getAuthorizationBasic(props?.layer?.security?.sourceId); - axios.get(src, { - responseType: 'blob', - headers + // The axios interceptor will handle authentication based on URL rules + axios.get(src, { + responseType: 'blob' + }) + .then((response) => { + const imageUrl = URL.createObjectURL(response.data); + setImageSrc(imageUrl); }) - .then((response) => { - const imageUrl = URL.createObjectURL(response.data); - setImageSrc(imageUrl); - }) - .catch((error) => { - console.error('Error fetching image:', error); - }); - } else { - setImageSrc(src); - } + .catch((error) => { + console.error('Error fetching image:', error); + }); // Clean up the URL object when the component unmounts return () => { diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 7dcc0277c80..7c0ed90c227 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -30,7 +30,6 @@ "mapboxAccessToken": "__ACCESS_TOKEN_MAPBOX__", "initialMapFilter": "", "ignoreMobileCss": false, - "useAuthenticationRules": true, "loadAfterTheme": true, "defaultMapOptions": { "cesium": { @@ -48,14 +47,24 @@ "localizedLayerStyles": { "name": "mapstore_language" }, - "authenticationRules": [ + "requestsConfigurationRules": [ { "urlPattern": ".*geostore.*", - "method": "bearer" + "headers": { + "Authorization": "Bearer ${securityToken}" + } }, { "urlPattern": ".*rest/config.*", - "method": "bearer" + "headers": { + "Authorization": "Bearer ${securityToken}" + } + }, + { + "urlPattern": "http(s)?\\:\\/\\/gs-stable\\.(geo-solutions\\.it|geosolutionsgroup\\.com)(\\:443|\\:80)?\\/geoserver/.*", + "params": { + "authkey": "${securityToken}" + } } ], "monitorState": [ diff --git a/web/client/epics/security.js b/web/client/epics/security.js index c5413b551cd..fcd50e7d07f 100644 --- a/web/client/epics/security.js +++ b/web/client/epics/security.js @@ -8,14 +8,32 @@ import Rx from 'rxjs'; import uniqBy from 'lodash/uniqBy'; import isArray from 'lodash/isArray'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import head from 'lodash/head'; +import castArray from 'lodash/castArray'; +import isEmpty from 'lodash/isEmpty'; +import { v4 as uuidv4 } from 'uuid'; import { DASHBOARD_LOADED } from '../actions/dashboard'; import { SET_CURRENT_STORY } from '../actions/geostory'; import { EDITOR_CHANGE } from '../actions/widgets'; import { UPDATE_ITEM } from '../actions/mediaEditor'; import { currentMediaTypeSelector, selectedItemSelector } from '../selectors/mediaEditor'; import { MAP_CONFIG_LOADED } from '../actions/config'; -import { setShowModalStatus, setProtectedServices } from '../actions/security'; -import { getCredentials } from '../utils/SecurityUtils'; +import { + setShowModalStatus, + setProtectedServices, + loadRequestsRules, + LOAD_REQUESTS_RULES, + UPDATE_REQUESTS_RULES +} from '../actions/security'; +import { + getCredentials, + convertAuthenticationRulesToRequestConfiguration +} from '../utils/SecurityUtils'; +import { LOCAL_CONFIG_LOADED } from '../actions/localConfig'; +import { layersSelector } from '../selectors/layers'; +import { changeLayerProperties } from '../actions/layers'; /** * checks if a content is protected in a map @@ -196,3 +214,76 @@ export const checkProtectedContentGeostoryEpic = (action$) => return Rx.Observable.of(setShowModalStatus(false)); }); +/** + * Epic to handle loading request configuration rules from config + */ +export const loadRequestsRulesFromConfigEpic = (action$) => + action$.ofType(LOCAL_CONFIG_LOADED) + .switchMap((action) => { + const config = action.config; + let rules = config?.requestsConfigurationRules ?? []; + const legacyRules = config?.authenticationRules ?? []; + const shouldUseLegacyRules = config?.useAuthenticationRules ?? false; + if (!isEmpty(legacyRules) && (isEmpty(rules) || shouldUseLegacyRules)) { + rules = convertAuthenticationRulesToRequestConfiguration(legacyRules); + } + return Rx.Observable.of(loadRequestsRules(rules)); + }); + +/** + * Helper function to determine which rules have changed + * Returns an array of URL patterns from rules that have changed + */ +const getChangedRuleUrlPatterns = (oldRules, newRules) => { + const makeMap = (rules) => new Map(rules.filter(r => r?.urlPattern).map(r => [r.urlPattern, r])); + const [oldMap, newMap] = [makeMap(oldRules), makeMap(newRules)]; + const changed = new Set([ + ...[...newMap].filter(([p, n]) => !oldMap.has(p) || !isEqual(oldMap.get(p), n)).map(([p]) => p), + ...[...oldMap].filter(([p]) => !newMap.has(p)).map(([p]) => p) + ]); + return [...changed]; +}; + +/** + * Epic to refresh layers when request configuration rules are updated + * This ensures that layers re-fetch tiles with the new authentication parameters + * Only refreshes layers whose URLs match changed rules + */ +export const refreshLayersOnRulesUpdateEpic = (action$, store) => + action$.ofType(LOAD_REQUESTS_RULES, UPDATE_REQUESTS_RULES) + .switchMap((action) => { + const state = store.getState(); + const newRules = get(action, 'rules', []); + const oldRules = state.security?.previousRules || []; + + // Get URL patterns of rules that have changed + const changedPatterns = getChangedRuleUrlPatterns(oldRules, newRules); + + if (isEmpty(changedPatterns)) { + // No rules changed, no need to refresh + return Rx.Observable.empty(); + } + + const layers = layersSelector(state) || []; + + // Find layers that should be refreshed based on matching changed rules + const layersToUpdate = []; + layers.forEach(layer => { + const url = head(castArray(layer.url)); + + // Check if any layer URL matches any changed rule pattern + const shouldRefresh = changedPatterns.some(pattern => url?.match(new RegExp(pattern, "i"))); + if (shouldRefresh) layersToUpdate.push(layer); + }); + + // Dispatch changeLayerProperties for each matching layer + const actions = layersToUpdate.map(layer => { + const newSecurity = layer.security + ? { ...layer.security, refreshHash: uuidv4() } + : { refreshHash: uuidv4() }; + return changeLayerProperties(layer.id, { security: newSecurity, visibility: false }); + }); + + return actions.length > 0 ? Rx.Observable.from(actions) : Rx.Observable.empty(); + }); + diff --git a/web/client/epics/wfsquery.js b/web/client/epics/wfsquery.js index 43019031866..b0aea92cdb2 100644 --- a/web/client/epics/wfsquery.js +++ b/web/client/epics/wfsquery.js @@ -55,7 +55,6 @@ import {selectedLayerSelector, useLayerFilterSelector} from '../selectors/featur import {layerLoad} from '../actions/layers'; import { mergeFiltersToOGC } from '../utils/FilterUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const extractInfo = (data, fields = []) => { return { @@ -136,8 +135,7 @@ export const featureTypeSelectedEpic = (action$, store) => .mergeAll(); } - const headers = getAuthorizationBasic(selectedLayer?.security?.sourceId); - return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {headers})) + return Rx.Observable.defer( () => axios.get(ConfigUtils.filterUrlParams(action.url, authkeyParamNameSelector(store.getState())) + '?service=WFS&version=1.1.0&request=DescribeFeatureType&typeName=' + action.typeName + '&outputFormat=application/json', {_msAuthSourceId: selectedLayer?.security?.sourceId})) .map((response) => { if (typeof response.data === 'object' && response.data.featureTypes && response.data.featureTypes[0]) { const info = extractInfo(response.data, action.fields); diff --git a/web/client/libs/ajax.js b/web/client/libs/ajax.js index 42e0d423456..6383a289e9a 100644 --- a/web/client/libs/ajax.js +++ b/web/client/libs/ajax.js @@ -10,10 +10,9 @@ import axios from 'axios'; import combineURLs from 'axios/lib/helpers/combineURLs'; import ConfigUtils from '../utils/ConfigUtils'; import { - isAuthenticationActivated, - getAuthenticationRule, - getToken, - getBasicAuthHeader + getRequestConfigurationByUrl, + getRequestConfigurationRule, + isRequestConfigurationActivated } from '../utils/SecurityUtils'; import isObject from 'lodash/isObject'; @@ -44,59 +43,45 @@ function addHeaderToAxiosConfig(axiosConfig, headerName, headerValue) { * authentication method based on the request URL. */ function addAuthenticationToAxios(axiosConfig) { - if (!axiosConfig || !axiosConfig.url || !isAuthenticationActivated()) { + if (!axiosConfig || !axiosConfig.url) { return axiosConfig; } const axiosUrl = combineURLs(axiosConfig.baseURL || '', axiosConfig.url); - const rule = getAuthenticationRule(axiosUrl); - switch (rule && rule.method) { - case 'browserWithCredentials': - { - axiosConfig.withCredentials = true; + // Extract custom sourceId from axios config if provided + const sourceId = axiosConfig._msAuthSourceId; + + // Only process authentication if sourceId is provided or request configuration is activated + if (!sourceId && !isRequestConfigurationActivated()) { return axiosConfig; } - case 'authkey': - { - const token = getToken(); - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + + // If request configuration is not activated but sourceId is provided, still need to handle basic auth + const { headers, params } = getRequestConfigurationByUrl(axiosUrl, undefined, sourceId); + + if (headers) { + Object.entries(headers).forEach(([headerName, headerValue]) => { + addHeaderToAxiosConfig(axiosConfig, headerName, headerValue); + }); } - case 'test': { - const token = rule ? rule.token : ""; - if (!token) { - return axiosConfig; - } - addParameterToAxiosConfig(axiosConfig, rule.authkeyParamName || 'authkey', token); - return axiosConfig; + if (params) { + Object.entries(params).forEach(([paramName, paramValue]) => { + addParameterToAxiosConfig(axiosConfig, paramName, paramValue); + }); } - case 'basic': - const basicAuthHeader = getBasicAuthHeader(); - if (!basicAuthHeader) { - return axiosConfig; - } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', basicAuthHeader); - return axiosConfig; - case 'bearer': - { - const token = getToken(); - if (!token) { - return axiosConfig; + + // Check for withCredentials + if (isRequestConfigurationActivated()) { + const rule = getRequestConfigurationRule(axiosUrl); + if (rule?.withCredentials) { + axiosConfig.withCredentials = true; } - addHeaderToAxiosConfig(axiosConfig, 'Authorization', "Bearer " + token); - return axiosConfig; - } - case 'header': { - Object.entries(rule.headers).map(([headerName, headerValue]) => addHeaderToAxiosConfig(axiosConfig, headerName, headerValue)); - return axiosConfig; - } - default: - // we cannot handle the required authentication method - return axiosConfig; } + + // Remove the custom prop from config to avoid it being sent as a regular param + delete axiosConfig._msAuthSourceId; + + return axiosConfig; } const checkSameOrigin = (uri) => { diff --git a/web/client/observables/wfs.js b/web/client/observables/wfs.js index 53cae4cb928..f542b67ab63 100644 --- a/web/client/observables/wfs.js +++ b/web/client/observables/wfs.js @@ -19,7 +19,6 @@ import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; import requestBuilder from '../utils/ogc/WFS/RequestBuilder'; import { getDefaultUrl } from '../utils/URLUtils'; -import { getAuthorizationBasic } from '../utils/SecurityUtils'; const {getFeature, query, sortBy, propertyName} = requestBuilder({ wfsVersion: "1.1.0" }); @@ -170,7 +169,6 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption } const { data, queryString } = getFeatureUtilities(searchUrl, filterObj, options, downloadOption); - const headers = getAuthorizationBasic(options.layer?.security?.sourceId || options.security?.sourceId); return Rx.Observable.defer(() => axios.post(queryString, data, { @@ -178,9 +176,9 @@ export const getXMLFeature = (searchUrl, filterObj, options = {}, downloadOption responseType: 'arraybuffer', headers: { 'Accept': `application/xml`, - 'Content-Type': `application/xml`, - ...headers - } + 'Content-Type': `application/xml` + }, + _msAuthSourceId: options.layer?.security?.sourceId || options.security?.sourceId })); }; @@ -278,14 +276,13 @@ export const getLayerJSONFeature = ({ search = {}, url, name, security } = {}, f }); export const describeFeatureType = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const url = toDescribeURL(layer); return Rx.Observable.defer(() => - axios.get(toDescribeURL(layer), {headers})).let(interceptOGCError); + axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})).let(interceptOGCError); }; export const getLayerWFSCapabilities = ({layer}) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - - return Rx.Observable.defer( () => axios.get(toLayerCapabilitiesURL(layer), {headers})) + const url = toLayerCapabilitiesURL(layer); + return Rx.Observable.defer( () => axios.get(url, {_msAuthSourceId: layer?.security?.sourceId})) .let(interceptOGCError) .switchMap( response => Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, { tagNameProcessors: [stripPrefix], diff --git a/web/client/observables/wms.js b/web/client/observables/wms.js index e71f00bde43..ad0f8695937 100644 --- a/web/client/observables/wms.js +++ b/web/client/observables/wms.js @@ -17,7 +17,7 @@ import axios from '../libs/ajax'; import { determineCrs, fetchProjRemotely, getProjUrl } from '../utils/CoordinatesUtils'; import { getCapabilitiesUrl } from '../utils/LayersUtils'; import { interceptOGCError } from '../utils/ObservableUtils'; -import { cleanAuthParamsFromURL, getAuthorizationBasic } from '../utils/SecurityUtils'; +import { cleanAuthParamsFromURL } from '../utils/SecurityUtils'; import { getDefaultUrl } from '../utils/URLUtils'; const proj4 = Proj4js; @@ -40,12 +40,12 @@ export const toDescribeLayerURL = ({name, search = {}, url} = {}) => { }); }; export const describeLayer = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer( () => axios.get(toDescribeLayerURL(l), {headers})).let(interceptOGCError); + const url = toDescribeLayerURL(l); + return Observable.defer( () => axios.get(url, {_msAuthSourceId: l?.security?.sourceId})).let(interceptOGCError); }; export const getLayerCapabilities = l => { - const headers = getAuthorizationBasic(l?.security?.sourceId); - return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), headers)) + const url = getCapabilitiesUrl(l); + return Observable.defer(() => WMS.getCapabilities(url, {_msAuthSourceId: l?.security?.sourceId})) .let(interceptOGCError) .map(c => WMS.parseLayerCapabilities(c, l)); }; diff --git a/web/client/observables/wps/execute.js b/web/client/observables/wps/execute.js index d75c265485b..678f07824f8 100644 --- a/web/client/observables/wps/execute.js +++ b/web/client/observables/wps/execute.js @@ -13,7 +13,6 @@ import { stripPrefix } from 'xml2js/lib/processors'; import axios from '../../libs/ajax'; import { getWPSURL } from './common'; -import { getAuthorizationBasic } from '../../utils/SecurityUtils'; /** * Contains routines pertaining to Execute WPS operation. @@ -194,13 +193,13 @@ export const makeOutputsExtractor = (...extractors) => * @returns {Observable} observable that emits result from axios.post */ export const executeProcessRequest = (url, payload, requestOptions = {}, layer) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); + const wpsUrl = getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}); return Observable.defer(() => - axios.post(getWPSURL(url, {"version": "1.0.0", "REQUEST": "Execute"}), payload, { + axios.post(wpsUrl, payload, { headers: { - 'Content-Type': 'application/xml', - ...headers + 'Content-Type': 'application/xml' }, + _msAuthSourceId: layer?.security?.sourceId, ...requestOptions }) ); diff --git a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx index a99f542437b..4fc141d6096 100644 --- a/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx +++ b/web/client/plugins/TOC/components/StyleBasedWMSJsonLegend.jsx @@ -133,7 +133,7 @@ class StyleBasedWMSJsonLegend extends React.Component { const cleanParams = clearNilValuesForParams(layer.params); const scale = this.getScale(props); const projection = normalizeSRS(props.projection || 'EPSG:3857', layer.allowedSRS); - const query = { + let query = { ...getWMSLegendConfig({ layer, format: LEGEND_FORMAT.JSON, @@ -146,8 +146,7 @@ class StyleBasedWMSJsonLegend extends React.Component { ...(cleanParams && cleanParams.SLD_BODY ? { SLD_BODY: cleanParams.SLD_BODY } : {}), ...(scale !== null ? { SCALE: scale } : {}) }; - addAuthenticationParameter(url, query); - + query = addAuthenticationParameter(url, query); return urlUtil.format({ host: urlObj.host, protocol: urlObj.protocol, diff --git a/web/client/reducers/security.js b/web/client/reducers/security.js index 6462daaba66..2329300428f 100644 --- a/web/client/reducers/security.js +++ b/web/client/reducers/security.js @@ -17,14 +17,23 @@ import { SESSION_VALID, CHANGE_PASSWORD, SET_SHOW_MODAL_STATUS, - SET_PROTECTED_SERVICES + SET_PROTECTED_SERVICES, + UPDATE_REQUESTS_RULES, + LOAD_REQUESTS_RULES_ERROR, + LOAD_REQUESTS_RULES } from '../actions/security'; import { RESET_CONTROLS, SET_CONTROL_PROPERTY } from '../actions/controls'; import { USERMANAGER_UPDATE_USER } from '../actions/users'; import {getUserAttributes} from '../utils/SecurityUtils'; import { cloneDeep, head } from 'lodash'; -const initialState = {user: null, errorCause: null}; +const initialState = { + user: null, + rules: [], + loading: false, + error: null, + lastRefresh: null +}; function security(state = initialState, action) { switch (action.type) { case USERMANAGER_UPDATE_USER: @@ -135,6 +144,27 @@ function security(state = initialState, action) { }; } + case UPDATE_REQUESTS_RULES: + return { + ...state, + previousRules: state.rules ?? [], + rules: action.rules ?? [], + error: null + }; + case LOAD_REQUESTS_RULES: + return { + ...state, + loading: false, + previousRules: state.rules ?? [], + rules: action.rules ?? [], + error: null + }; + case LOAD_REQUESTS_RULES_ERROR: + return { + ...state, + loading: false, + error: action.error + }; default: return state; } diff --git a/web/client/selectors/catalog.js b/web/client/selectors/catalog.js index b3656eba9db..ac805a7a9e0 100644 --- a/web/client/selectors/catalog.js +++ b/web/client/selectors/catalog.js @@ -48,7 +48,17 @@ export const layerErrorSelector = (state) => get(state, "catalog.layerError"); export const searchTextSelector = (state) => get(state, "catalog.searchOptions.text", ""); export const isActiveSelector = (state) => get(state, "controls.toolbar.active") === "metadataexplorer" || get(state, "controls.metadataexplorer.enabled"); export const authkeyParamNameSelector = (state) => { - return (get(state, "localConfig.authenticationRules") || []).filter(a => a.method === "authkey").map(r => r.authkeyParamName) || []; + const rules = state?.security?.rules || []; + const authKeyParams = rules + .filter(rule => rule.params) + .map(rule => { + const authKeyParam = Object.keys(rule.params).find(key => + rule.params[key] && (rule.params[key].includes('${securityToken}')) + ); + return authKeyParam; + }) + .filter(param => param); + return authKeyParams; }; export const pageSizeSelector = (state) => get(state, "catalog.pageSize", 4); export const delayAutoSearchSelector = (state) => get(state, "catalog.delayAutoSearch", 1000); diff --git a/web/client/selectors/security.js b/web/client/selectors/security.js index 30dd31fe0f3..8da873b8638 100644 --- a/web/client/selectors/security.js +++ b/web/client/selectors/security.js @@ -53,7 +53,8 @@ export const securityTokenSelector = state => state.security && state.security.t export const isAdminUserSelector = (state) => userRoleSelector(state) === "ADMIN"; export const isUserSelector = (state) => userRoleSelector(state) === "USER"; export const authProviderSelector = state => state.security && state.security.authProvider; - +export const requestsRulesSelector = state => get(state, 'security.rules', []); +export const requestsRulesEnabledSelector = state => get(state, 'security.rulesEnabled', false); /** * Creates a selector that checks if user is allowed to edit * something based on the user's role and groups diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index e1f465eaa2d..50c1a05da22 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -817,11 +817,8 @@ export const setCustomUtils = (type, fun) => { export const getAuthenticationParam = options => { const urls = getURLs(isArray(options.url) ? options.url : [options.url]); - let authenticationParam = {}; - urls.forEach(url => { - addAuthenticationParameter(url, authenticationParam, options.securityToken); - }); - return authenticationParam; + // Use first URL since all URLs in array should have same auth config + return addAuthenticationParameter(urls[0] || '', {}, options.securityToken, options.security?.sourceId); }; /** * Removes google backgrounds and select an alternative one as visible diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index 0d0b7e0dec5..6bac2600764 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -13,6 +13,8 @@ import head from "lodash/head"; import isNil from "lodash/isNil"; import isArray from "lodash/isArray"; import isEmpty from "lodash/isEmpty"; +import template from "lodash/template"; +import get from "lodash/get"; import {setStore as stateSetStore, getState} from "./StateUtils"; @@ -112,110 +114,253 @@ export function findUserAttributeValue(attributeName) { } /** - * Returns an array with the configured authentication rules. If no rules - * were configured an empty array is returned. + * Parses request configuration by replacing variables with actual values using lodash template + * @param {Object} config - Configuration object with headers/params + * @param {Object} securityProperties - Security properties to replace variables + * @returns {Object} Parsed configuration with replaced variables */ -export function getAuthenticationRules() { - return ConfigUtils.getConfigProp('authenticationRules') || []; -} +const parseRequestConfiguration = (config = {}, securityProperties) => { + return Object.fromEntries( + Object.entries(config) + .map((entry) => { + const [name, value] = entry; + if (typeof value === 'string' && value.includes('${')) { + try { + // Use lodash template for variable substitution + const compiled = template(value); + const result = compiled(securityProperties); + return [name, result]; + } catch (error) { + console.warn(`Template parsing error for ${name}:`, error); + return entry; // Return original if template fails + } + } + return entry; + }) + .filter(entry => entry) + ); +}; /** - * Checks if authentication is activated or not. + * Legacy compatibility: Converts old authenticationRules to new format + * @param {Array} authRules - Old authentication rules + * @returns {Array} New request configuration rules */ -export function isAuthenticationActivated() { - return ConfigUtils.getConfigProp('useAuthenticationRules') || false; -} +export const convertAuthenticationRulesToRequestConfiguration = (authRules = []) => { + return authRules.map(rule => { + const newRule = { + urlPattern: rule.urlPattern + }; + + switch (rule.method) { + case 'bearer': + newRule.headers = { + 'Authorization': 'Bearer ${securityToken}' + }; + break; + case 'authkey': + newRule.params = { + [rule.authkeyParamName || 'authkey']: '${securityToken}' + }; + break; + case 'basic': + newRule.headers = { + 'Authorization': '${authHeader}' + }; + break; + case 'header': + newRule.headers = rule.headers || {}; + break; + case 'browserWithCredentials': + newRule.withCredentials = true; + break; + default: + // Unknown method, skip this rule + return null; + } + + return newRule; + }).filter(rule => rule !== null); +}; /** - * Returns the authentication method that should be used for the provided URL. - * We go through the authentication rules and find the first one that matches - * the provided URL, if no rule matches the provided URL undefined is returned. + * Gets all request configuration rules from Redux state or config + * Automatically converts authenticationRules to new format if requestsConfigurationRules is missing + * @returns {Array} Array of request configuration rules */ -export function getAuthenticationMethod(url) { - const foundRule = head(getAuthenticationRules().filter( - rule => rule && rule.urlPattern && url.match(new RegExp(rule.urlPattern, "i")))); - return foundRule?.method; -} +export const getRequestConfigurationRules = () => { + // First try to get from Redux state (if available) + const stateRules = get(getState(), 'security.rules', []); + if (!isEmpty(stateRules)) { + return stateRules; + } + + // Try to get new format from config + const configRules = ConfigUtils.getConfigProp('requestsConfigurationRules'); + if (!isEmpty(configRules)) { + return configRules; + } + + // If new format is missing, convert old authenticationRules format + const authRules = ConfigUtils.getConfigProp('authenticationRules'); + if (!isEmpty(authRules)) { + return convertAuthenticationRulesToRequestConfiguration(authRules); + } + + // No rules found + return []; +}; /** - * Returns the authentication rule that should be used for the provided URL. - * We go through the authentication rules and find the first one that matches - * the provided URL, if no rule matches the provided URL undefined is returned. + * Gets the request configuration rule that matches the provided URL + * @param {string} url - The URL to match against rules + * @returns {Object|null} Matching rule or null */ -export function getAuthenticationRule(url) { - return head(getAuthenticationRules().filter( - rule => rule && rule.urlPattern && url.match(new RegExp(rule.urlPattern, "i")))); -} +export const getRequestConfigurationRule = (url) => { + const rules = getRequestConfigurationRules(); + return head(rules.filter( + rule => rule && rule.urlPattern && url.match(new RegExp(rule.urlPattern, "i")) + )); +}; -export function getAuthKeyParameter(url) { - const foundRule = getAuthenticationRule(url); - return foundRule?.authkeyParamName ?? 'authkey'; -} +/** + * Checks if request configuration is activated + * Returns true only when user is authenticated and rules are present + * @returns {boolean} True if request configuration is activated + */ +export const isRequestConfigurationActivated = () => { + // Check if user is authenticated (has a token) + const token = getToken(); + if (!token) { + return false; // Not authenticated + } -export function getAuthenticationHeaders(url, securityToken, security) { - if (!url || !isAuthenticationActivated()) { - return null; + // Check if redux state exist + const state = getState(); + if (!isEmpty(state?.security?.rules)) { + return true; + } + + const newRules = ConfigUtils.getConfigProp('requestsConfigurationRules'); + if (!isEmpty(newRules)) { + return true; } - const storedProtectedService = getCredentials(security?.sourceId); - if (security && storedProtectedService) { - return { - "Authorization": `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` + + // Legacy support + const useLegacyRules = ConfigUtils.getConfigProp('useAuthenticationRules'); + const oldRules = ConfigUtils.getConfigProp('authenticationRules'); + if (isNil(useLegacyRules)) { + return !isEmpty(oldRules); + } + return useLegacyRules; +}; + +/** + * it creates the headers function for axios config, if it finds a reference in sessionStorage + * @param {string} protectedId the id of the protected service to look for in sessionStorage + * @returns {object} the headers Basic + */ +export const getAuthorizationBasic = (protectedId) => { + let headers = {}; + const storedProtectedService = getCredentials(protectedId); + if (!isEmpty(storedProtectedService)) { + headers = { + Authorization: `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` }; } - switch (getAuthenticationMethod(url)) { - case 'bearer': { - const token = !isNil(securityToken) ? securityToken : getToken(); - if (!token) { - return null; + return headers; +}; + +/** + * Gets request configuration (headers and params) for a given URL + * This is the main function that centralizes all request configuration logic + * @param {string} url - The URL to get configuration for + * @param {string} securityToken - Optional security token override + * @param {string} [sourceId] - Optional source ID for sessionStorage-based credentials + * @returns {Object} Object containing headers and/or params + */ +export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { + if (!url || !isRequestConfigurationActivated()) { + if (!isNil(sourceId)) { + return { headers: getAuthorizationBasic(sourceId) }; } - return { - "Authorization": `Bearer ${token}` - }; + return {}; } - case 'header': { - const rule = getAuthenticationRule(url); - return rule.headers; + + const rule = getRequestConfigurationRule(url); + if (!rule) return {}; + + const token = !isNil(securityToken) ? securityToken : getToken(); + let authHeader = getBasicAuthHeader(); + + // Fallback to sessionStorage credentials sourceId and credentials is stored + let basicAuthHeader; + if (sourceId) { + basicAuthHeader = getAuthorizationBasic(sourceId); + authHeader = basicAuthHeader?.Authorization; } - default: - // we cannot handle the required authentication method - return null; + + const securityProperties = { + ...(!isNil(token) && { securityToken: token }), + ...(!isNil(authHeader) && { authHeader }) + }; + + const parsedHeaders = parseRequestConfiguration(rule.headers, securityProperties); + const parsedParams = parseRequestConfiguration(rule.params, securityProperties); + + let finalHeaders; + if (!isEmpty(parsedHeaders)) { + finalHeaders = parsedHeaders; + } else if (sourceId && !isEmpty(basicAuthHeader)) { + finalHeaders = basicAuthHeader; } + + return { + ...(!isEmpty(finalHeaders) && { headers: finalHeaders }), + ...(!isEmpty(parsedParams) && { params: parsedParams }) + }; +}; + +export function getAuthKeyParameter(url) { + // Use the new request configuration system + const rule = getRequestConfigurationRule(url); + if (rule && rule.params) { + // Find the parameter that contains the securityToken placeholder + const authKeyParam = Object.keys(rule.params).find(key => + rule.params[key] && (rule.params[key].includes('${securityToken}')) + ); + if (authKeyParam) { + return authKeyParam; + } + } + return 'authkey'; +} + +export function getAuthenticationHeaders(url, securityToken, security) { + const requestConfig = getRequestConfigurationByUrl(url, securityToken, security?.sourceId); + if (!isEmpty(requestConfig.headers)) { + return requestConfig.headers; + } + return null; } /** * This method will add query parameter based authentications to an object * containing query parameters. */ -export function addAuthenticationParameter(url, parameters, securityToken) { - if (!url || !isAuthenticationActivated()) { - return parameters; - } - switch (getAuthenticationMethod(url)) { - case 'authkey': { - const token = !isNil(securityToken) ? securityToken : getToken(); - if (!token) { - return parameters; - } - const authParam = getAuthKeyParameter(url); - return Object.assign(parameters || {}, {[authParam]: token}); - } - case 'test': { - const rule = getAuthenticationRule(url); - const token = rule ? rule.token : ""; - const authParam = getAuthKeyParameter(url); - return Object.assign(parameters || {}, { [authParam]: token }); - } - default: - // we cannot handle the required authentication method - return parameters; +export function addAuthenticationParameter(url, parameters, securityToken, sourceId) { + const requestConfig = getRequestConfigurationByUrl(url, securityToken, sourceId); + if (!isEmpty(requestConfig.params)) { + return {...parameters, ...requestConfig.params}; } + return parameters; } /** * This method will add query parameter based authentications to an url. */ export function addAuthenticationToUrl(url) { - if (!url || !isAuthenticationActivated()) { + if (!url || !isRequestConfigurationActivated()) { return url; } const parsedUrl = URL.parse(url, true); @@ -249,22 +394,6 @@ export function cleanAuthParamsFromURL(url) { return ConfigUtils.filterUrlParams(url, [getAuthKeyParameter(url)].filter(p => p)); } -/** - * it creates the headers function for axios config, if it finds a reference in sessionStorage - * @param {string} protectedId the id of the protected service to look for in sessionStorage - * @returns {object} the headers Basic - */ -export const getAuthorizationBasic = (protectedId) => { - let headers = {}; - const storedProtectedService = getCredentials(protectedId); - if (!isEmpty(storedProtectedService)) { - headers = { - Authorization: `Basic ${btoa(storedProtectedService.username + ":" + storedProtectedService.password)}` - }; - } - return headers; -}; - /** * This utility class will get information about the current logged user directly from the store. */ @@ -281,10 +410,6 @@ const SecurityUtils = { getUserAttributes, findUserAttribute, findUserAttributeValue, - getAuthenticationRules, - isAuthenticationActivated, - getAuthenticationMethod, - getAuthenticationRule, addAuthenticationToUrl, addAuthenticationParameter, clearNilValuesForParams, @@ -292,6 +417,11 @@ const SecurityUtils = { getAuthKeyParameter, cleanAuthParamsFromURL, getAuthenticationHeaders, + getRequestConfigurationByUrl, + getRequestConfigurationRules, + getRequestConfigurationRule, + isRequestConfigurationActivated, + convertAuthenticationRulesToRequestConfiguration, USER_GROUP_ALL }; diff --git a/web/client/utils/__tests__/SecurityUtils-test.js b/web/client/utils/__tests__/SecurityUtils-test.js index 979782070da..35776b7ed66 100644 --- a/web/client/utils/__tests__/SecurityUtils-test.js +++ b/web/client/utils/__tests__/SecurityUtils-test.js @@ -183,29 +183,35 @@ describe('Test security utils methods', () => { expect(attributeValue).toBe("263c6917-543f-43e3-8e1a-6a0d29952f72"); }); - it('test get authentication method for an url', () => { - // mocking the authentication rules - ConfigUtils.setConfigProp('authenticationRules', authenticationRules); - expect(SecurityUtils.getAuthenticationRules().length).toBe(3); - // basic authentication should be found - let authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('basic'); - // authkey authentication should be found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('authkey'); - // not-supported authentication should be found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.not-supported.com/?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toBe('not-supported'); - // no authentication method found - authenticationMethod = SecurityUtils.getAuthenticationMethod('http://www.no-authentication.com/?parameter1=value1¶meter2=value2'); - expect(authenticationMethod).toNotExist(); + it('test get request configuration rule for an url', () => { + // Set up request configuration rules (converted from old authenticationRules format) + // Note: unsupported methods are filtered out, so we expect 2 rules (geoserver and some-site) + const requestConfigRules = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authenticationRules); + expect(requestConfigRules.length).toBe(2); + + // Set the rules in config + ConfigUtils.setConfigProp('requestsConfigurationRules', requestConfigRules); + setSecurityInfo(securityInfoToken); + + // Test basic authentication rule should be found and converted + let rule = SecurityUtils.getRequestConfigurationRule('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); + expect(rule).toExist(); + expect(rule.urlPattern).toBe('.*some-site.*'); + + // Test authkey authentication rule should be found + rule = SecurityUtils.getRequestConfigurationRule('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); + expect(rule).toExist(); + + // Test that no rule matches + rule = SecurityUtils.getRequestConfigurationRule('http://www.no-matching.com/?parameter1=value1¶meter2=value2'); + expect(rule).toNotExist(); }); it('test add authkey authentication to url', () => { - // mocking the authentication rules + // Convert authentication rules to new format and set them + const requestConfigRules = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authenticationRules); ConfigUtils.setConfigProp("useAuthenticationRules", true); - ConfigUtils.setConfigProp('authenticationRules', authenticationRules); - expect(SecurityUtils.getAuthenticationRules().length).toBe(3); + ConfigUtils.setConfigProp('requestsConfigurationRules', requestConfigRules); // authkey authentication with no user let urlWithAuthentication = SecurityUtils.addAuthenticationToUrl('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); expect(urlWithAuthentication).toBe('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); @@ -223,6 +229,7 @@ describe('Test security utils methods', () => { expect(urlWithAuthentication).toBe('http://www.some-site.com/index?parameter1=value1¶meter2=value2'); // authkey authentication with a user providing a uuid but authentication deactivated ConfigUtils.setConfigProp("useAuthenticationRules", false); + ConfigUtils.setConfigProp('requestsConfigurationRules', []); setSecurityInfo(securityInfoC); urlWithAuthentication = SecurityUtils.addAuthenticationToUrl('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); expect(urlWithAuthentication).toBe('http://www.some-site.com/geoserver?parameter1=value1¶meter2=value2'); @@ -240,10 +247,17 @@ describe('Test security utils methods', () => { expect(SecurityUtils.getAuthenticationHeaders("http://header-site.com/something", null)).toEqual({'X-Auth-Token': 'goodtoken'}); }); it('test getAuthenticationHeaders using basic auth', () => { + const creds = {username: "testuser", password: "testpass"}; + SecurityUtils.setCredentials("id2", creds); setSecurityInfo(securityInfoToken); - ConfigUtils.setConfigProp("useAuthenticationRules", true); - ConfigUtils.setConfigProp('authenticationRules', headerAuthenticationRules); - expect(SecurityUtils.getAuthenticationHeaders("http://header-site.com/something", null, {sourceId: "id2"})).toEqual({Authorization: "Basic dW5kZWZpbmVkOnVuZGVmaW5lZA=="}); + ConfigUtils.setConfigProp("useAuthenticationRules", false); + // Use a rule that doesn't match, so it falls back to sourceId basic auth + ConfigUtils.setConfigProp('requestsConfigurationRules', []); + + const result = SecurityUtils.getAuthenticationHeaders("http://other-site.com/something", null, {sourceId: "id2"}); + expect(result).toExist(); + expect(result.Authorization).toExist(); + expect(result.Authorization).toInclude('Basic'); }); it('cleanAuthParamsFromURL', () => { // mocking the authentication rules @@ -282,4 +296,454 @@ describe('Test security utils methods', () => { headers = SecurityUtils.getAuthorizationBasic(); expect(headers).toEqual({}); }); + + describe('getRequestConfigurationByUrl', () => { + it('should return empty object when not activated', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api'); + expect(result).toEqual({}); + }); + + it('should return headers configuration with Bearer token', () => { + const rules = [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api'); + expect(result.headers).toExist(); + expect(result.headers.Authorization).toBe('Bearer goodtoken'); + }); + + it('should return params configuration with authkey', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/geoserver/wms'); + expect(result.params).toExist(); + expect(result.params.authkey).toBe('goodtoken'); + }); + + it('should use sourceId for basic auth when provided', () => { + const creds = {username: "testuser", password: "testpass"}; + SecurityUtils.setCredentials("source123", creds); + + const result = SecurityUtils.getRequestConfigurationByUrl('https://example.com/api', null, "source123"); + expect(result.headers).toExist(); + expect(result.headers.Authorization).toExist(); + }); + }); + + describe('isRequestConfigurationActivated', () => { + it('should return false when user has no token', () => { + setSecurityInfo(securityInfoA); // No token + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(false); + }); + + it('should return true when user has token and rules exist in state', () => { + const stateWithRules = { + user: securityInfoC.user, + token: 'test-token', + rules: [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ] + }; + setSecurityInfo(stateWithRules); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + + it('should return true when user has token and rules exist in config', () => { + setSecurityInfo(securityInfoToken); + ConfigUtils.setConfigProp('requestsConfigurationRules', [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + + it('should return true for legacy authenticationRules when enabled', () => { + setSecurityInfo(securityInfoToken); + ConfigUtils.setConfigProp('authenticationRules', authenticationRules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + const result = SecurityUtils.isRequestConfigurationActivated(); + expect(result).toBe(true); + }); + }); + + describe('convertAuthenticationRulesToRequestConfiguration', () => { + it('should convert bearer method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'bearer' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*api.*'); + expect(result[0].headers.Authorization).toBe('Bearer ${securityToken}'); + }); + + it('should convert authkey method', () => { + const authRules = [ + { urlPattern: '.*geoserver.*', method: 'authkey', authkeyParamName: 'token' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*geoserver.*'); + expect(result[0].params.token).toBe('${securityToken}'); + }); + + it('should convert basic method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'basic' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].headers.Authorization).toBe('${authHeader}'); + }); + + it('should convert header method', () => { + const authRules = [ + { + urlPattern: '.*api.*', + method: 'header', + headers: { 'X-API-Key': 'test123' } + } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].headers['X-API-Key']).toBe('test123'); + }); + + it('should convert browserWithCredentials method', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'browserWithCredentials' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].withCredentials).toBe(true); + }); + + it('should filter out unsupported methods', () => { + const authRules = [ + { urlPattern: '.*api.*', method: 'bearer' }, + { urlPattern: '.*unsupported.*', method: 'unsupported' } + ]; + const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); + expect(result.length).toBe(1); + expect(result[0].urlPattern).toBe('.*api.*'); + }); + }); + + describe('getRequestConfigurationRules', () => { + it('should return rules from Redux state first', () => { + const rulesInState = [ + { urlPattern: '.*api.*', headers: { 'Authorization': 'Bearer ${securityToken}' } } + ]; + setSecurityInfo({ user: securityInfoToken.user, token: 'test', rules: rulesInState }); + + const result = SecurityUtils.getRequestConfigurationRules(); + expect(result).toEqual(rulesInState); + }); + + it('should return rules from config when state is empty', () => { + const rulesInConfig = [ + { urlPattern: '.*api.*', headers: { 'Authorization': 'Bearer ${securityToken}' } } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rulesInConfig); + + const result = SecurityUtils.getRequestConfigurationRules(); + expect(result).toEqual(rulesInConfig); + }); + + it('should convert legacy authenticationRules when new format missing', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + ConfigUtils.setConfigProp('authenticationRules', authenticationRules); + + const result = SecurityUtils.getRequestConfigurationRules(); + // Unsupported methods are filtered out, so we expect 2 rules + expect(result.length).toBe(2); + expect(result[0].urlPattern).toBe('.*geoserver.*'); + }); + }); + + describe('getAuthKeyParameter', () => { + it('should return authkey parameter from rule', () => { + const rules = [ + { + urlPattern: '.*api.*', + params: { 'customAuthKey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + + const result = SecurityUtils.getAuthKeyParameter('https://example.com/api'); + expect(result).toBe('customAuthKey'); + }); + + it('should return default authkey when no rule found', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + + const result = SecurityUtils.getAuthKeyParameter('https://example.com/api'); + expect(result).toBe('authkey'); + }); + }); + + describe('addAuthenticationParameter', () => { + it('should add authentication params to existing parameters object', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const existingParams = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://geoserver.example.com/wms', existingParams); + + expect(result).toExist(); + expect(result.param1).toBeTruthy(); + expect(result.authkey).toBeTruthy(); + expect(result.authkey).toBe('goodtoken'); + }); + + it('should not mutate original parameters object', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const originalParams = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://geoserver.example.com/wms', originalParams); + + expect(result).toExist(); + expect(originalParams).toEqual({ param1: 'value1' }); // Original unchanged + expect(result).toNotEqual(originalParams); // New object + }); + + it('should return original params when no auth params available', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + const params = { param1: 'value1' }; + + const result = SecurityUtils.addAuthenticationParameter('https://example.com/api', params); + expect(result).toEqual(params); + }); + + it('should pass sourceId to getRequestConfigurationByUrl', () => { + const creds = { username: "testuser", password: "testpass" }; + SecurityUtils.setCredentials("testSource", creds); + + const params = { param1: 'value1' }; + const result = SecurityUtils.addAuthenticationParameter('https://example.com/api', params, null, "testSource"); + + expect(result).toExist(); + expect(result.param1).toBe('value1'); + }); + }); + + describe('addAuthenticationToSLD', () => { + it('should add authentication to SLD URL', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const layerParams = { + SLD: 'http://geoserver.example.com/sld?LAYER=layer1' + }; + const options = { securityToken: 'testtoken' }; + + const result = SecurityUtils.addAuthenticationToSLD(layerParams, options); + expect(result.SLD).toInclude('authkey=testtoken'); + }); + + it('should return original layerParams when no SLD', () => { + const layerParams = { LAYERS: 'layer1' }; + const options = { securityToken: 'testtoken' }; + + const result = SecurityUtils.addAuthenticationToSLD(layerParams, options); + expect(result).toEqual(layerParams); + }); + }); + + describe('getAuthenticationHeaders', () => { + it('should return headers from request config', () => { + const rules = [ + { + urlPattern: '.*api.*', + headers: { 'Authorization': 'Bearer ${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null); + expect(result).toExist(); + expect(result.Authorization).toBe('Bearer goodtoken'); + }); + + it('should return null when no headers available', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules', null); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null); + expect(result).toBe(null); + }); + + it('should use sourceId for basic auth', () => { + const creds = { username: "testuser", password: "testpass" }; + SecurityUtils.setCredentials("testSource", creds); + + const result = SecurityUtils.getAuthenticationHeaders('https://api.example.com', null, { sourceId: "testSource" }); + expect(result).toExist(); + expect(result.Authorization).toExist(); + }); + }); + + describe('getToken', () => { + it('should return token from security info', () => { + setSecurityInfo(securityInfoToken); + const token = SecurityUtils.getToken(); + expect(token).toBe('goodtoken'); + }); + + it('should return null when no token', () => { + setSecurityInfo(securityInfoA); + const token = SecurityUtils.getToken(); + expect(token).toBe(undefined); + }); + }); + + describe('getBasicAuthHeader', () => { + it('should return basic auth header', () => { + const securityInfoWithAuth = { ...securityInfoToken, authHeader: 'Basic dGVzdDp0ZXN0' }; + setSecurityInfo(securityInfoWithAuth); + const authHeader = SecurityUtils.getBasicAuthHeader(); + expect(authHeader).toBe('Basic dGVzdDp0ZXN0'); + }); + }); + + describe('getRefreshToken', () => { + it('should return refresh token', () => { + const securityInfoWithRefresh = { ...securityInfoToken, refresh_token: 'refresh-token-123' }; + setSecurityInfo(securityInfoWithRefresh); + const refreshToken = SecurityUtils.getRefreshToken(); + expect(refreshToken).toBe('refresh-token-123'); + }); + }); + + describe('getUser', () => { + it('should return user from security info', () => { + setSecurityInfo(securityInfoToken); + const user = SecurityUtils.getUser(); + expect(user).toExist(); + expect(user.name).toBe(securityInfoC.user.name); + }); + + it('should return undefined when no user', () => { + setSecurityInfo({}); + const user = SecurityUtils.getUser(); + expect(user).toBe(undefined); + }); + }); + + describe('getSecurityInfo', () => { + it('should return security info object', () => { + setSecurityInfo(securityInfoToken); + const info = SecurityUtils.getSecurityInfo(); + expect(info).toExist(); + expect(info.user).toExist(); + expect(info.token).toBe('goodtoken'); + }); + + it('should return empty object when no security info', () => { + setSecurityInfo({}); + const info = SecurityUtils.getSecurityInfo(); + expect(info).toEqual({}); + }); + }); + + describe('addAuthenticationToUrl', () => { + it('should add authkey parameter to URL when activated', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + ConfigUtils.setConfigProp('useAuthenticationRules', true); + setSecurityInfo(securityInfoToken); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1'; + const result = SecurityUtils.addAuthenticationToUrl(url); + expect(result).toInclude('authkey=goodtoken'); + }); + + it('should return original URL when not activated', () => { + ConfigUtils.setConfigProp('requestsConfigurationRules/E', null); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1'; + const result = SecurityUtils.addAuthenticationToUrl(url); + expect(result).toBe(url); + }); + + it('should return original URL when null', () => { + const result = SecurityUtils.addAuthenticationToUrl(null); + expect(result).toBe(null); + }); + }); + + describe('cleanAuthParamsFromURL', () => { + it('should remove authkey parameter from URL', () => { + const rules = [ + { + urlPattern: '.*geoserver.*', + params: { 'authkey': '${securityToken}' } + } + ]; + ConfigUtils.setConfigProp('requestsConfigurationRules', rules); + + const url = 'http://geoserver.example.com/wms?LAYERS=layer1&authkey=test123'; + const result = SecurityUtils.cleanAuthParamsFromURL(url); + expect(result).toNotInclude('authkey'); + }); + + it('should handle URLs without auth parameters', () => { + const url = 'http://example.com/api?param1=value1'; + const result = SecurityUtils.cleanAuthParamsFromURL(url); + expect(result).toExist(); + }); + }); }); diff --git a/web/client/utils/cesium/WMSUtils.js b/web/client/utils/cesium/WMSUtils.js index f2463aafcda..9dd08877bf8 100644 --- a/web/client/utils/cesium/WMSUtils.js +++ b/web/client/utils/cesium/WMSUtils.js @@ -8,7 +8,7 @@ import * as Cesium from 'cesium'; import { isArray } from 'lodash'; -import { addAuthenticationToSLD, getAuthenticationHeaders } from "../SecurityUtils"; +import { addAuthenticationParameter, addAuthenticationToSLD, getAuthenticationHeaders } from "../SecurityUtils"; import { getProxyUrl } from "../ProxyUtils"; import ConfigUtils from "../ConfigUtils"; import { creditsToAttribution, getAuthenticationParam, getURLs, getWMSVendorParams } from "../LayersUtils"; @@ -96,13 +96,13 @@ export const wmsToCesiumOptionsBIL = (layer) => { export function wmsToCesiumOptions(options) { var opacity = options.opacity !== undefined ? options.opacity : 1; - const params = optionsToVendorParams(options); + let params = optionsToVendorParams(options); const cr = options.credits; const credit = cr ? new Cesium.Credit(creditsToAttribution(cr)) : options.attribution; // NOTE: can we use opacity to manage visibility? const urls = getURLs(isArray(options.url) ? options.url : [options.url]); const headers = getAuthenticationHeaders(urls[0], options.securityToken, options.security); - + params = addAuthenticationParameter(urls[0], params, options.securityToken); return { url: new Cesium.Resource({ url: "{s}", diff --git a/web/client/utils/mapinfo/wfs.js b/web/client/utils/mapinfo/wfs.js index e81a9e0d1ee..a864a7084d5 100644 --- a/web/client/utils/mapinfo/wfs.js +++ b/web/client/utils/mapinfo/wfs.js @@ -16,7 +16,7 @@ import { describeFeatureType, getFeature } from '../../api/WFS'; import { extractGeometryAttributeName } from '../WFSLayerUtils'; -import {addAuthenticationToSLD, getAuthorizationBasic} from '../SecurityUtils'; +import {addAuthenticationToSLD} from '../SecurityUtils'; // if the url uses following constant means the whole workflow is managed client side // and prevent request to a service @@ -99,7 +99,6 @@ const getIdentifyGeometry = point => { export default { buildRequest, getIdentifyFlow: (layer = {}, baseURL, defaultParams) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); const { point, features, ...baseParams } = defaultParams || {}; if (features) { if (baseURL && baseURL !== CLIENT_WORKFLOW) { @@ -112,7 +111,7 @@ export default { ...baseParams } }, filterIdsCQL); - return Observable.defer(() => getFeature(baseURL, layer.name, params, {headers})); + return Observable.defer(() => getFeature(baseURL, layer.name, params, {_msAuthSourceId: layer?.security?.sourceId})); } return Observable.of({ data: { @@ -134,8 +133,12 @@ export default { } }, - params: Object.assign({}, layer.baseParams, layer.params, baseParams) + params: { + ...layer.baseParams, + ...layer.params, + ...baseParams + } }); - return getFeature(baseURL, layer.name, params, {headers}); + return getFeature(baseURL, layer.name, params, {_msAuthSourceId: layer?.security?.sourceId}); })); }}; diff --git a/web/client/utils/mapinfo/wms.js b/web/client/utils/mapinfo/wms.js index f488ef62b2f..414791bc3d7 100644 --- a/web/client/utils/mapinfo/wms.js +++ b/web/client/utils/mapinfo/wms.js @@ -16,7 +16,7 @@ import { generateEnvString } from '../LayerLocalizationUtils'; import axios from "../../libs/ajax"; // import {parseString} from "xml2js"; // import {stripPrefix} from "xml2js/lib/processors"; -import {addAuthenticationToSLD, getAuthorizationBasic} from '../SecurityUtils'; +import {addAuthenticationToSLD} from '../SecurityUtils'; import { interceptOGCError } from '../ObservableUtils'; export default { /** @@ -97,8 +97,7 @@ export default { * @param {object} params for the request */ getIdentifyFlow: (layer, basePath, params) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - return Observable.defer(() => axios.get(basePath, { params, headers })) + return Observable.defer(() => axios.get(basePath, { params, _msAuthSourceId: layer?.security?.sourceId })) .let(interceptOGCError); } }; diff --git a/web/client/utils/mapinfo/wmts.js b/web/client/utils/mapinfo/wmts.js index ee002dbe518..1025c2f246d 100644 --- a/web/client/utils/mapinfo/wmts.js +++ b/web/client/utils/mapinfo/wmts.js @@ -21,8 +21,6 @@ import { } from '../WMTSUtils'; import {getLayerUrl} from '../LayersUtils'; import {optionsToVendorParams} from '../VendorParamsUtils'; -import { getAuthorizationBasic } from '../SecurityUtils'; - import {isObject, isNil, get} from 'lodash'; import Rx, {Observable} from "rxjs"; @@ -113,8 +111,7 @@ export default { }; }, getIdentifyFlow: (layer, basePath, params) => { - const headers = getAuthorizationBasic(layer?.security?.sourceId); - return Observable.defer(() => axios.get(basePath, { params, headers })) + return Observable.defer(() => axios.get(basePath, { params, _msAuthSourceId: layer?.security?.sourceId })) .catch((e) => { if (e.data.indexOf("ExceptionReport") > 0) { return Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, { From 631e25a8d683c588f5fc20af56fea04670bfceda Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 30 Oct 2025 00:36:38 +0530 Subject: [PATCH 02/94] updated unit tests --- docs/developer-guide/local-config.md | 3 +- web/client/components/misc/SecureImage.jsx | 49 +++++++++++--- web/client/epics/security.js | 4 +- web/client/libs/ajax.js | 12 ++-- web/client/observables/wms.js | 6 +- .../selectors/__tests__/catalog-test.js | 9 ++- web/client/utils/SecurityUtils.js | 64 ++++++++++++++----- .../utils/__tests__/SecurityUtils-test.js | 2 +- 8 files changed, 108 insertions(+), 41 deletions(-) diff --git a/docs/developer-guide/local-config.md b/docs/developer-guide/local-config.md index 8a95038d673..c2539bac570 100644 --- a/docs/developer-guide/local-config.md +++ b/docs/developer-guide/local-config.md @@ -165,9 +165,8 @@ For configuring plugins, see the [Configuring Plugins Section](plugins-documenta - `useAuthenticationRules` (deprecated): if this flag is set to true, legacy `authenticationRules` will be used. The new `requestsConfigurationRules` system does not require this flag and is always active when rules are present. - `requestsConfigurationRules`: is an array of objects that contain rules to match for request configuration. Each rule has a `urlPattern` regex to match and either `headers`, `params`, or `withCredentials` configuration. If the URL of a request matches the `urlPattern` of a rule, the configuration will be applied to the request. - **Available variables for template substitution (ES6 template syntax `${variable}`):** + **Available variable for template substitution (ES6 template syntax `${variable}`):** - `${securityToken}` - The current MapStore session token (automatically replaced) - - `${authHeader}` - The basic authentication header (automatically replaced) **Configuration options:** - `headers` - Object containing HTTP headers to add to matching requests. Example: diff --git a/web/client/components/misc/SecureImage.jsx b/web/client/components/misc/SecureImage.jsx index 858f0c8fa08..038c1b40c87 100644 --- a/web/client/components/misc/SecureImage.jsx +++ b/web/client/components/misc/SecureImage.jsx @@ -8,6 +8,8 @@ import React, { useEffect, useState } from 'react'; import axios from '../../libs/ajax'; +import { getAuthenticationMethod, getAuthKeyParameter, getToken } from '../../utils/SecurityUtils'; +import { updateUrlParams } from '../../utils/URLUtils'; const SecureImage = ({ @@ -26,17 +28,44 @@ const SecureImage = ({ } }; useEffect(() => { - // The axios interceptor will handle authentication based on URL rules - axios.get(src, { - responseType: 'blob' - }) - .then((response) => { - const imageUrl = URL.createObjectURL(response.data); - setImageSrc(imageUrl); + const authMethod = getAuthenticationMethod(src); + + if (authMethod === "bearer") { + axios.get(src, { + responseType: 'blob' }) - .catch((error) => { - console.error('Error fetching image:', error); - }); + .then((response) => { + const imageUrl = URL.createObjectURL(response.data); + setImageSrc(imageUrl); + }) + .catch((error) => { + console.error('Error fetching image:', error); + }); + } else if (authMethod === "authkey") { + const authParam = getAuthKeyParameter(src); + const token = getToken(); + if (authParam && token) { + const newSrc = updateUrlParams(src, {[authParam]: token}); + setImageSrc(newSrc); + } else { + setImageSrc(src); + } + + } else if (props?.layer?.security?.sourceId) { + axios.get(src, { + responseType: 'blob', + _msAuthSourceId: props?.layer?.security?.sourceId + }) + .then((response) => { + const imageUrl = URL.createObjectURL(response.data); + setImageSrc(imageUrl); + }) + .catch((error) => { + console.error('Error fetching image:', error); + }); + } else { + setImageSrc(src); + } // Clean up the URL object when the component unmounts return () => { diff --git a/web/client/epics/security.js b/web/client/epics/security.js index fcd50e7d07f..d1210512ede 100644 --- a/web/client/epics/security.js +++ b/web/client/epics/security.js @@ -223,8 +223,8 @@ export const loadRequestsRulesFromConfigEpic = (action$) => const config = action.config; let rules = config?.requestsConfigurationRules ?? []; const legacyRules = config?.authenticationRules ?? []; - const shouldUseLegacyRules = config?.useAuthenticationRules ?? false; - if (!isEmpty(legacyRules) && (isEmpty(rules) || shouldUseLegacyRules)) { + const useLegacyRules = config?.useAuthenticationRules ?? false; + if (isEmpty(rules) && !isEmpty(legacyRules) && useLegacyRules) { rules = convertAuthenticationRulesToRequestConfiguration(legacyRules); } return Rx.Observable.of(loadRequestsRules(rules)); diff --git a/web/client/libs/ajax.js b/web/client/libs/ajax.js index 6383a289e9a..8b991db6879 100644 --- a/web/client/libs/ajax.js +++ b/web/client/libs/ajax.js @@ -10,8 +10,11 @@ import axios from 'axios'; import combineURLs from 'axios/lib/helpers/combineURLs'; import ConfigUtils from '../utils/ConfigUtils'; import { + getAuthenticationMethod, + getAuthorizationBasic, getRequestConfigurationByUrl, getRequestConfigurationRule, + getToken, isRequestConfigurationActivated } from '../utils/SecurityUtils'; @@ -20,6 +23,7 @@ import omitBy from 'lodash/omitBy'; import isNil from 'lodash/isNil'; import urlUtil from 'url'; import { getProxyCacheByUrl, setProxyCacheByUrl } from '../utils/ProxyUtils'; +import { isEmpty } from 'lodash'; /** * Internal helper that adds an extra paramater to an axios configuration. @@ -51,10 +55,10 @@ function addAuthenticationToAxios(axiosConfig) { // Extract custom sourceId from axios config if provided const sourceId = axiosConfig._msAuthSourceId; - // Only process authentication if sourceId is provided or request configuration is activated - if (!sourceId && !isRequestConfigurationActivated()) { - return axiosConfig; - } + const method = getAuthenticationMethod(axiosUrl); + if (method === "bearer" && !getToken()) return axiosConfig; + if (method === "authkey" && !getToken()) return axiosConfig; + if (method === "basic" && isEmpty(getAuthorizationBasic(sourceId))) return axiosConfig; // If request configuration is not activated but sourceId is provided, still need to handle basic auth const { headers, params } = getRequestConfigurationByUrl(axiosUrl, undefined, sourceId); diff --git a/web/client/observables/wms.js b/web/client/observables/wms.js index ad0f8695937..a5c8952593d 100644 --- a/web/client/observables/wms.js +++ b/web/client/observables/wms.js @@ -40,12 +40,10 @@ export const toDescribeLayerURL = ({name, search = {}, url} = {}) => { }); }; export const describeLayer = l => { - const url = toDescribeLayerURL(l); - return Observable.defer( () => axios.get(url, {_msAuthSourceId: l?.security?.sourceId})).let(interceptOGCError); + return Observable.defer( () => axios.get(toDescribeLayerURL(l), {_msAuthSourceId: l?.security?.sourceId})).let(interceptOGCError); }; export const getLayerCapabilities = l => { - const url = getCapabilitiesUrl(l); - return Observable.defer(() => WMS.getCapabilities(url, {_msAuthSourceId: l?.security?.sourceId})) + return Observable.defer(() => WMS.getCapabilities(getCapabilitiesUrl(l), {_msAuthSourceId: l?.security?.sourceId})) .let(interceptOGCError) .map(c => WMS.parseLayerCapabilities(c, l)); }; diff --git a/web/client/selectors/__tests__/catalog-test.js b/web/client/selectors/__tests__/catalog-test.js index 7ed69533e96..380925ea842 100644 --- a/web/client/selectors/__tests__/catalog-test.js +++ b/web/client/selectors/__tests__/catalog-test.js @@ -232,7 +232,14 @@ describe('Test catalog selectors', () => { expect(retVal).toBe("someval"); }); it('test authkeyParamNameSelector with authkey params set', () => { - const authkeyParamNames = authkeyParamNameSelector(state); + const authkeyParamNames = authkeyParamNameSelector({security: {rules: [ + { + urlPattern: ".*geoserver.*", + params: { + "ms2-authkey": "${securityToken}" + } + } + ]}}); expect(authkeyParamNames).toExist(); expect(authkeyParamNames.length).toBe(1); expect(authkeyParamNames[0]).toBe("ms2-authkey"); diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index 6bac2600764..b84c7783164 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -128,7 +128,8 @@ const parseRequestConfiguration = (config = {}, securityProperties) => { try { // Use lodash template for variable substitution const compiled = template(value); - const result = compiled(securityProperties); + let result = compiled(securityProperties); + result = result === "" ? undefined : result; return [name, result]; } catch (error) { console.warn(`Template parsing error for ${name}:`, error); @@ -165,7 +166,12 @@ export const convertAuthenticationRulesToRequestConfiguration = (authRules = []) break; case 'basic': newRule.headers = { - 'Authorization': '${authHeader}' + 'Authorization': '${securityToken}' + }; + break; + case 'test': + newRule.params = { + [rule.authkeyParamName || 'authkey']: rule.token ?? "" }; break; case 'header': @@ -223,18 +229,39 @@ export const getRequestConfigurationRule = (url) => { )); }; +/** + * Gets the authentication method for a given URL based on request configuration rules. + * Infers the method type from the rule structure for backward compatibility. + * @param {string} url - The URL to check. + * @returns {string} The inferred authentication method. + */ +export function getAuthenticationMethod(url) { + const rule = getRequestConfigurationRule(url); + if (!rule) return null; + + if (rule.params && Object.keys(rule.params).length > 0) { + const hasTokenParam = Object.values(rule.params) + .some(val => typeof val === 'string' && val.includes('${securityToken}')); + if (hasTokenParam) return 'authkey'; + } + + if (rule.headers) { + const authHeader = rule.headers.Authorization || rule.headers.authorization; + if (typeof authHeader === 'string') { + if (authHeader.includes('Bearer')) return 'bearer'; + if (authHeader.includes('Basic')) return 'basic'; + } + } + + return null; +} + /** * Checks if request configuration is activated * Returns true only when user is authenticated and rules are present * @returns {boolean} True if request configuration is activated */ export const isRequestConfigurationActivated = () => { - // Check if user is authenticated (has a token) - const token = getToken(); - if (!token) { - return false; // Not authenticated - } - // Check if redux state exist const state = getState(); if (!isEmpty(state?.security?.rules)) { @@ -302,7 +329,7 @@ export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { const securityProperties = { ...(!isNil(token) && { securityToken: token }), - ...(!isNil(authHeader) && { authHeader }) + ...(!isNil(authHeader) && { authHeader: authHeader }) }; const parsedHeaders = parseRequestConfiguration(rule.headers, securityProperties); @@ -344,16 +371,24 @@ export function getAuthenticationHeaders(url, securityToken, security) { return null; } +export function clearNilValuesForParams(params = {}) { + return Object.keys(params).reduce((pre, cur) => { + const value = params[cur]; + return !isNil(value) && value !== '' ? {...pre, [cur]: value} : pre; + }, {}); +} + /** * This method will add query parameter based authentications to an object * containing query parameters. */ export function addAuthenticationParameter(url, parameters, securityToken, sourceId) { + let params = {...(parameters ?? {})}; const requestConfig = getRequestConfigurationByUrl(url, securityToken, sourceId); if (!isEmpty(requestConfig.params)) { - return {...parameters, ...requestConfig.params}; + params = {...params, ...requestConfig.params}; } - return parameters; + return clearNilValuesForParams(params); } /** @@ -370,12 +405,6 @@ export function addAuthenticationToUrl(url) { return URL.format(parsedUrl); } -export function clearNilValuesForParams(params = {}) { - return Object.keys(params).reduce((pre, cur) => { - return !isNil(params[cur]) ? {...pre, [cur]: params[cur]} : pre; - }, {}); -} - export function addAuthenticationToSLD(layerParams, options) { if (layerParams.SLD) { const parsed = URL.parse(layerParams.SLD, true); @@ -420,6 +449,7 @@ const SecurityUtils = { getRequestConfigurationByUrl, getRequestConfigurationRules, getRequestConfigurationRule, + getAuthenticationMethod, isRequestConfigurationActivated, convertAuthenticationRulesToRequestConfiguration, USER_GROUP_ALL diff --git a/web/client/utils/__tests__/SecurityUtils-test.js b/web/client/utils/__tests__/SecurityUtils-test.js index 35776b7ed66..dacffde3e60 100644 --- a/web/client/utils/__tests__/SecurityUtils-test.js +++ b/web/client/utils/__tests__/SecurityUtils-test.js @@ -417,7 +417,7 @@ describe('Test security utils methods', () => { ]; const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); expect(result.length).toBe(1); - expect(result[0].headers.Authorization).toBe('${authHeader}'); + expect(result[0].headers.Authorization).toBe('${securityToken}'); }); it('should convert header method', () => { From 883951cbcd31405b28ad75ccc1d746ec50d96398 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 30 Oct 2025 01:56:40 +0530 Subject: [PATCH 03/94] code refactor --- web/client/libs/ajax.js | 2 +- web/client/utils/SecurityUtils.js | 41 +++++++++++++------ .../utils/__tests__/SecurityUtils-test.js | 8 +--- web/client/utils/cesium/WMSUtils.js | 6 +-- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/web/client/libs/ajax.js b/web/client/libs/ajax.js index 8b991db6879..41810d0f1a2 100644 --- a/web/client/libs/ajax.js +++ b/web/client/libs/ajax.js @@ -58,7 +58,7 @@ function addAuthenticationToAxios(axiosConfig) { const method = getAuthenticationMethod(axiosUrl); if (method === "bearer" && !getToken()) return axiosConfig; if (method === "authkey" && !getToken()) return axiosConfig; - if (method === "basic" && isEmpty(getAuthorizationBasic(sourceId))) return axiosConfig; + if (method === "basic" && sourceId && isEmpty(getAuthorizationBasic(sourceId))) return axiosConfig; // If request configuration is not activated but sourceId is provided, still need to handle basic auth const { headers, params } = getRequestConfigurationByUrl(axiosUrl, undefined, sourceId); diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index b84c7783164..e58f7d6c176 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -166,7 +166,7 @@ export const convertAuthenticationRulesToRequestConfiguration = (authRules = []) break; case 'basic': newRule.headers = { - 'Authorization': '${securityToken}' + 'Authorization': 'Basic ${securityToken}' }; break; case 'test': @@ -298,6 +298,22 @@ export const getAuthorizationBasic = (protectedId) => { return headers; }; +/** + * Filter out headers/params that still contain unresolved template variables + * @param {object} obj - The object to filter + * @returns {object} The filtered object + */ +const filterUnresolvedTemplates = (obj) => { + if (typeof obj !== 'object' || !obj) return obj; + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => !String(v).includes('${securityToken}')) + ); +}; + +const basicAuthorizationHeader = (sourceId) => { + return !isNil(sourceId) ? { headers: getAuthorizationBasic(sourceId) } : {}; +}; + /** * Gets request configuration (headers and params) for a given URL * This is the main function that centralizes all request configuration logic @@ -308,14 +324,12 @@ export const getAuthorizationBasic = (protectedId) => { */ export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { if (!url || !isRequestConfigurationActivated()) { - if (!isNil(sourceId)) { - return { headers: getAuthorizationBasic(sourceId) }; - } - return {}; + return basicAuthorizationHeader(sourceId); } - const rule = getRequestConfigurationRule(url); - if (!rule) return {}; + if (!rule) { + return basicAuthorizationHeader(sourceId); + } const token = !isNil(securityToken) ? securityToken : getToken(); let authHeader = getBasicAuthHeader(); @@ -326,25 +340,25 @@ export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { basicAuthHeader = getAuthorizationBasic(sourceId); authHeader = basicAuthHeader?.Authorization; } - const securityProperties = { ...(!isNil(token) && { securityToken: token }), ...(!isNil(authHeader) && { authHeader: authHeader }) }; - const parsedHeaders = parseRequestConfiguration(rule.headers, securityProperties); const parsedParams = parseRequestConfiguration(rule.params, securityProperties); let finalHeaders; - if (!isEmpty(parsedHeaders)) { - finalHeaders = parsedHeaders; + const filteredHeaders = filterUnresolvedTemplates(parsedHeaders); + if (!isEmpty(filteredHeaders)) { + finalHeaders = filteredHeaders; } else if (sourceId && !isEmpty(basicAuthHeader)) { finalHeaders = basicAuthHeader; } + const filteredParams = filterUnresolvedTemplates(parsedParams); return { ...(!isEmpty(finalHeaders) && { headers: finalHeaders }), - ...(!isEmpty(parsedParams) && { params: parsedParams }) + ...(!isEmpty(filteredParams) && { params: filteredParams }) }; }; @@ -374,7 +388,8 @@ export function getAuthenticationHeaders(url, securityToken, security) { export function clearNilValuesForParams(params = {}) { return Object.keys(params).reduce((pre, cur) => { const value = params[cur]; - return !isNil(value) && value !== '' ? {...pre, [cur]: value} : pre; + // return !isNil(value) && value !== '' ? {...pre, [cur]: value} : pre; + return !isNil(value) ? {...pre, [cur]: value} : pre; }, {}); } diff --git a/web/client/utils/__tests__/SecurityUtils-test.js b/web/client/utils/__tests__/SecurityUtils-test.js index dacffde3e60..fffecc4d34e 100644 --- a/web/client/utils/__tests__/SecurityUtils-test.js +++ b/web/client/utils/__tests__/SecurityUtils-test.js @@ -347,12 +347,6 @@ describe('Test security utils methods', () => { }); describe('isRequestConfigurationActivated', () => { - it('should return false when user has no token', () => { - setSecurityInfo(securityInfoA); // No token - const result = SecurityUtils.isRequestConfigurationActivated(); - expect(result).toBe(false); - }); - it('should return true when user has token and rules exist in state', () => { const stateWithRules = { user: securityInfoC.user, @@ -417,7 +411,7 @@ describe('Test security utils methods', () => { ]; const result = SecurityUtils.convertAuthenticationRulesToRequestConfiguration(authRules); expect(result.length).toBe(1); - expect(result[0].headers.Authorization).toBe('${securityToken}'); + expect(result[0].headers.Authorization).toBe('Basic ${securityToken}'); }); it('should convert header method', () => { diff --git a/web/client/utils/cesium/WMSUtils.js b/web/client/utils/cesium/WMSUtils.js index 9dd08877bf8..1293f80a65a 100644 --- a/web/client/utils/cesium/WMSUtils.js +++ b/web/client/utils/cesium/WMSUtils.js @@ -7,7 +7,7 @@ */ import * as Cesium from 'cesium'; -import { isArray } from 'lodash'; +import { isArray, castArray } from 'lodash'; import { addAuthenticationParameter, addAuthenticationToSLD, getAuthenticationHeaders } from "../SecurityUtils"; import { getProxyUrl } from "../ProxyUtils"; import ConfigUtils from "../ConfigUtils"; @@ -68,8 +68,8 @@ export const getProxy = (options) => { * @returns {object} converted BIL options */ export const wmsToCesiumOptionsBIL = (layer) => { - let url = layer.url; - const headers = getAuthenticationHeaders(url, layer.securityToken, layer.security); + const url = layer.url; + const headers = getAuthenticationHeaders(castArray(url)[0], layer.securityToken, layer.security); const params = getAuthenticationParam(layer); // specific options for terrain provider now are inside the options parameter // we still use layer object for retrocompatibility From 57b14362bfd37514a40821b3ee8f82ac32ddd420 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 30 Oct 2025 12:19:47 +0530 Subject: [PATCH 04/94] remove test --- web/client/epics/security.js | 3 +-- web/client/utils/SecurityUtils.js | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/web/client/epics/security.js b/web/client/epics/security.js index d1210512ede..50443b06cdd 100644 --- a/web/client/epics/security.js +++ b/web/client/epics/security.js @@ -281,9 +281,8 @@ export const refreshLayersOnRulesUpdateEpic = (action$, store) => const newSecurity = layer.security ? { ...layer.security, refreshHash: uuidv4() } : { refreshHash: uuidv4() }; - return changeLayerProperties(layer.id, { security: newSecurity, visibility: false }); + return changeLayerProperties(layer.id, { security: newSecurity }); }); return actions.length > 0 ? Rx.Observable.from(actions) : Rx.Observable.empty(); }); - diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index e58f7d6c176..bbf67216211 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -334,7 +334,6 @@ export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { const token = !isNil(securityToken) ? securityToken : getToken(); let authHeader = getBasicAuthHeader(); - // Fallback to sessionStorage credentials sourceId and credentials is stored let basicAuthHeader; if (sourceId) { basicAuthHeader = getAuthorizationBasic(sourceId); @@ -388,7 +387,6 @@ export function getAuthenticationHeaders(url, securityToken, security) { export function clearNilValuesForParams(params = {}) { return Object.keys(params).reduce((pre, cur) => { const value = params[cur]; - // return !isNil(value) && value !== '' ? {...pre, [cur]: value} : pre; return !isNil(value) ? {...pre, [cur]: value} : pre; }, {}); } From 763e0cf1e64f2207463197eb6cabebfc2de0beb3 Mon Sep 17 00:00:00 2001 From: Suren Date: Thu, 30 Oct 2025 15:57:24 +0530 Subject: [PATCH 05/94] refactor config rule function --- web/client/utils/SecurityUtils.js | 41 +++++++++++++------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/web/client/utils/SecurityUtils.js b/web/client/utils/SecurityUtils.js index bbf67216211..d1c47e30632 100644 --- a/web/client/utils/SecurityUtils.js +++ b/web/client/utils/SecurityUtils.js @@ -326,38 +326,31 @@ export const getRequestConfigurationByUrl = (url, securityToken, sourceId) => { if (!url || !isRequestConfigurationActivated()) { return basicAuthorizationHeader(sourceId); } + const rule = getRequestConfigurationRule(url); - if (!rule) { - return basicAuthorizationHeader(sourceId); - } + if (!rule) return basicAuthorizationHeader(sourceId); - const token = !isNil(securityToken) ? securityToken : getToken(); - let authHeader = getBasicAuthHeader(); + const token = securityToken ?? getToken(); + const basicAuth = sourceId ? getAuthorizationBasic(sourceId) : null; + const authHeader = basicAuth?.Authorization ?? getBasicAuthHeader(); - let basicAuthHeader; - if (sourceId) { - basicAuthHeader = getAuthorizationBasic(sourceId); - authHeader = basicAuthHeader?.Authorization; - } - const securityProperties = { + const securityProps = { ...(!isNil(token) && { securityToken: token }), ...(!isNil(authHeader) && { authHeader: authHeader }) }; - const parsedHeaders = parseRequestConfiguration(rule.headers, securityProperties); - const parsedParams = parseRequestConfiguration(rule.params, securityProperties); - - let finalHeaders; - const filteredHeaders = filterUnresolvedTemplates(parsedHeaders); - if (!isEmpty(filteredHeaders)) { - finalHeaders = filteredHeaders; - } else if (sourceId && !isEmpty(basicAuthHeader)) { - finalHeaders = basicAuthHeader; - } - const filteredParams = filterUnresolvedTemplates(parsedParams); + const parsedHeaders = filterUnresolvedTemplates( + parseRequestConfiguration(rule.headers, securityProps) + ); + const params = filterUnresolvedTemplates( + parseRequestConfiguration(rule.params, securityProps) + ); + const headers = !isEmpty(parsedHeaders) + ? parsedHeaders : (!isEmpty(basicAuth) && sourceId ? basicAuth : undefined); + return { - ...(!isEmpty(finalHeaders) && { headers: finalHeaders }), - ...(!isEmpty(filteredParams) && { params: filteredParams }) + ...(!isEmpty(headers) && { headers: headers }), + ...(!isEmpty(params) && { params: params }) }; }; From 19e678788dd800c03f765c778b8254030e71d7bb Mon Sep 17 00:00:00 2001 From: Lorenzo Natali Date: Fri, 31 Oct 2025 11:27:33 +0100 Subject: [PATCH 06/94] #11577 Doc build fixed for node 22. Build strategy (#11611) * #11577 Doc build fixed for node 22. Build strategy * Updated java versions * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Fix requirements table * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz * Apply suggestion from @offtherailz --- docs/developer-guide/requirements.md | 23 ++++++++++------------- package.json | 4 ++-- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/developer-guide/requirements.md b/docs/developer-guide/requirements.md index b4dab0d98b1..e778c9e2dd6 100644 --- a/docs/developer-guide/requirements.md +++ b/docs/developer-guide/requirements.md @@ -8,30 +8,27 @@ You can download a java web container like *Apache Tomcat* from and *Java JRE* | Tool | Link | Minimum | Recommended | Maximum | |--------|----------------------------------------------------|---------|-------------|---------------| -| Java | [link](https://www.java.com/it/download/) | 81 | 11 | 112 | +| Java | [link](https://www.java.com/it/download/) | 81 | 11 | 172 | | Tomcat | [link](https://tomcat.apache.org/download-80.cgi) | 8.5 | 9 | 92 | ## Debug / Build These tools needs to be installed (other than **Java** in versions above above): -| Tool | Link | Minimum | Recommended | Maximum | -|-----------------------|------------------------------------------------------------|---------|-------------|---------------------| -| npm | [link](https://www.npmjs.com/get-npm) | 8 | 10 | | -| NodeJS | [link](https://nodejs.org/en/) | 20 | 20 | 203 | -| Java (JDK) | [link](https://www.java.com/en/download/help/develop.html) | 8 | 9 | 112 | -| Maven | [link](https://maven.apache.org/download.cgi) | 3.1.0 | 3.6 | | -| python4 | [link](https://www.python.org/downloads/) | 2.7.9 | 3.7 | | +| Tool | Link | Minimum | Recommended | Maximum | +|-----------------------|------------------------------------------------------------|---------|-------------|-----------------| +| npm | [link](https://www.npmjs.com/get-npm) | 8 | 10 | | +| NodeJS | [link](https://nodejs.org/en/) | 20 | 20 | 253 | +| Java (JDK) | [link](https://www.java.com/en/download/help/develop.html) | 8 | 11 | 17 | +| Maven | [link](https://maven.apache.org/download.cgi) | 3.1.0 | 3.6 | | +| python4 | [link](https://www.python.org/downloads/) | 2.7.9 | 3.7 | | !!! notes Here some notes about some requirements and reasons for max version indicated, for future improvements and maintenance : - 1 Java 8 is the minimum version required for running MapStore, but it is not compatible in case you want to use the print module. In this case, you need to use Java 11. - - 2 About Java and Tomcat maximum versions: - - For execution, MapStore is well tested on Java v11. - - Build with success with v11, only smoke tests passing on v13, errors with v16.(Details on issue [#6935](https://github.com/geosolutions-it/MapStore2/issues/6935)) - - Running with Tomcat 10 causes this issue [#7524](https://github.com/geosolutions-it/MapStore2/issues/7524). - - 3 See issue [#11577](https://github.com/geosolutions-it/MapStore2/issues/11577) for details about this limit (for now only for documentation build). + - 2 Running with Tomcat 10 causes this issue [#7524](https://github.com/geosolutions-it/MapStore2/issues/7524). + - 3 Latest version tested. - 4 Python is only needed for building documentation. ## Running in Production diff --git a/package.json b/package.json index c382cee8b16..325394ac688 100644 --- a/package.json +++ b/package.json @@ -280,9 +280,9 @@ "doc": "npm run jsdoc:build", "cleandoc": "npm run jsdoc:clean", "doctest": "npm run jsdoc:test", - "jsdoc:build": "npm run jsdoc:check && docma -c build/docma-config.json --dest web/docs", + "jsdoc:build": "NODE_OPTIONS=\"--no-deprecation\" npm run jsdoc:check && docma -c build/docma-config.json --dest web/docs", "jsdoc:clean": "premove web/docs && premove web/client/mapstore/docs", - "jsdoc:test": "docma -c build/docma-config.json --dest web/client/mapstore/docs && echo documentation is accessible from the mapstore/docs path when running npm start", + "jsdoc:test": "NODE_OPTIONS=\"--no-deprecation\" docma -c build/docma-config.json --dest web/client/mapstore/docs && echo documentation is accessible from the mapstore/docs path when running npm start", "jsdoc:check": "node ./utility/doc/jsDocConfigCheck.js", "jsdoc:update": "node ./utility/doc/jsDocConfigUpdate.js", "doc:build": "mkdocs build", From 996c2feee678ce495a5557c0b19ff072f54e78cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 14:09:42 +0100 Subject: [PATCH 07/94] Bump versions on master for release-branch (#11649) Co-authored-by: github-actions --- binary/bin-war/pom.xml | 4 ++-- binary/pom.xml | 2 +- java/pom.xml | 2 +- java/printing/pom.xml | 2 +- java/services/pom.xml | 2 +- java/web/pom.xml | 2 +- package.json | 2 +- pom.xml | 7 ++----- product/pom.xml | 2 +- 9 files changed, 11 insertions(+), 14 deletions(-) diff --git a/binary/bin-war/pom.xml b/binary/bin-war/pom.xml index 362345d95a8..eab9b82f774 100644 --- a/binary/bin-war/pom.xml +++ b/binary/bin-war/pom.xml @@ -3,12 +3,12 @@ it.geosolutions.mapstore mapstore-binary - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-bin-war war - 1.10-SNAPSHOT + 1.11-SNAPSHOT MapStore 2 Release Module WAR Creates the war for the binary package, adding customization (e.g. h2 database) http://www.geo-solutions.it diff --git a/binary/pom.xml b/binary/pom.xml index 2e31c26c963..99b2e1bb802 100644 --- a/binary/pom.xml +++ b/binary/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-binary diff --git a/java/pom.xml b/java/pom.xml index ed92c28664f..e6da6d89cd0 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/java/printing/pom.xml b/java/printing/pom.xml index 740e49324f7..ad91fd23caa 100644 --- a/java/printing/pom.xml +++ b/java/printing/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/java/services/pom.xml b/java/services/pom.xml index a75312b32b3..73bbfd8a886 100644 --- a/java/services/pom.xml +++ b/java/services/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/java/web/pom.xml b/java/web/pom.xml index 957260f4193..8e984de1a7e 100644 --- a/java/web/pom.xml +++ b/java/web/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-java - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore diff --git a/package.json b/package.json index 325394ac688..3e908f0bf87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapstore2", - "version": "0.11.0", + "version": "0.12.0", "description": "MapStore 2", "repository": "https://github.com/geosolutions-it/MapStore2", "main": "index.js", diff --git a/pom.xml b/pom.xml index ab8a530e1ad..30cd44a03e4 100644 --- a/pom.xml +++ b/pom.xml @@ -1,13 +1,10 @@ - + 4.0.0 it.geosolutions.mapstore mapstore-root pom - 1.10-SNAPSHOT + 1.11-SNAPSHOT MapStore Root diff --git a/product/pom.xml b/product/pom.xml index ec3ce9eb018..1f4ceef8486 100644 --- a/product/pom.xml +++ b/product/pom.xml @@ -3,7 +3,7 @@ it.geosolutions.mapstore mapstore-root - 1.10-SNAPSHOT + 1.11-SNAPSHOT it.geosolutions.mapstore mapstore-product From 5b493fb587f733c7256e310c2048c27ff60ac0db Mon Sep 17 00:00:00 2001 From: RowHeat <40065760+rowheat02@users.noreply.github.com> Date: Tue, 4 Nov 2025 19:27:01 +0545 Subject: [PATCH 08/94] Improve the Street Smart plugin view for 3D maps visualization(Re-login enabled) #11374 (#11628) --- .../components/CyclomediaView/Credentials.jsx | 12 +++++-- .../CyclomediaView/CyclomediaView.js | 8 ++++- .../__tests__/Credentials-test.jsx | 26 ++++++++++++++++ .../__tests__/CyclomediaView-test.jsx | 31 +++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx b/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx index c59137a17ee..8b6cad23d58 100644 --- a/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx +++ b/web/client/plugins/StreetView/components/CyclomediaView/Credentials.jsx @@ -1,6 +1,6 @@ import React, {useState} from 'react'; import Message from '../../../../components/I18N/Message'; -import { Form, Button, ControlLabel, FormControl, Glyphicon } from 'react-bootstrap'; +import { Form, Button, ControlLabel, FormControl, Glyphicon, Alert } from 'react-bootstrap'; import tooltip from '../../../../components/misc/enhancers/tooltip'; const ButtonT = tooltip(Button); /** @@ -11,9 +11,10 @@ const ButtonT = tooltip(Button); * @prop {object} credentials object with username and password * @prop {boolean} showCredentialsForm show form * @prop {function} setShowCredentialsForm function to set showCredentialsForm + * @prop {boolean} isCredentialsInvalid flag to indicate if credentials are invalid * @returns {JSX.Element} The rendered component */ -export default ({setCredentials = () => {}, credentials, showCredentialsForm, setShowCredentialsForm = () => {}}) => { +export default ({setCredentials = () => {}, credentials, showCredentialsForm, setShowCredentialsForm = () => {}, isCredentialsInvalid = false}) => { const [username, setUsername] = useState(credentials?.username || ''); const [password, setPassword] = useState(credentials?.password || ''); const onSubmit = () => { @@ -38,10 +39,15 @@ export default ({setCredentials = () => {}, credentials, showCredentialsForm, se setUsername(e.target.value)}/> setPassword(e.target.value)}/> + {isCredentialsInvalid && ( + + + + )}
{ - credentials?.username && credentials?.password && diff --git a/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js b/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js index 302676c459a..cb15cd6f2e1 100644 --- a/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js +++ b/web/client/plugins/StreetView/components/CyclomediaView/CyclomediaView.js @@ -215,6 +215,9 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo setInitializing(false); setError(err); setReloadAllowed(true); + if (isInvalidCredentials(err) >= 0) { + setShowCredentialsForm(true); + } if (err) { console.error('Cyclomedia API: init: error: ' + err); } @@ -328,8 +331,11 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo showCredentialsForm={showCredentialsForm} setShowCredentialsForm={setShowCredentialsForm} credentials={credentials} + isCredentialsInvalid={isInvalidCredentials(error) >= 0} setCredentials={(newCredentials) => { setCredentials(newCredentials); + setError(null); + setReload(prev => prev + 1); }}/>} {showLogout && initialized @@ -368,7 +374,7 @@ const CyclomediaView = ({ apiKey, style, location = {}, setPov = () => {}, setLo {getErrorMessage(error, {srs})}
- {initialized || reloadAllowed ?
+ ); +} + +/** + * Edit IP button component for card actions + */ +export function EditIP({ component, onEdit, resource: ip }) { + const Component = component; + return ( + onEdit(ip)} + glyph="wrench" + labelId="ipManager.editTooltip" + square + /> + ); +} + +/** + * Delete IP button component for card actions + */ +export function DeleteIP({ component, onDelete, resource: ip }) { + const Component = component; + return ( + onDelete(ip)} + glyph="trash" + labelId="ipManager.deleteTooltip" + square + bsStyle="danger" + /> + ); +} + +/** + * IP Filter search component for toolbar + */ +export function IPFilter({ onSearch, query }) { + const handleFieldChange = (params) => { + onSearch({ params: { q: params } }); + }; + return ( + + ); +} + diff --git a/web/client/components/manager/ipmanager/IPDialog.jsx b/web/client/components/manager/ipmanager/IPDialog.jsx new file mode 100644 index 00000000000..e3e2aa00bf0 --- /dev/null +++ b/web/client/components/manager/ipmanager/IPDialog.jsx @@ -0,0 +1,111 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useEffect } from 'react'; +import { Button, Glyphicon, FormGroup, ControlLabel, FormControl, HelpBlock } from 'react-bootstrap'; +import Modal from '../../misc/Modal'; +import Message from '../../I18N/Message'; +import ConfirmDialog from '../../layout/ConfirmDialog'; +import Text from '../../layout/Text'; +import { validateIPAddress } from '../../../utils/IPValidationUtils'; + +/** + * Dialog for creating/editing IP ranges + */ +export default function IPDialog({ show, ip, onSave, onClose, loading = false }) { + const [ipAddress, setIpAddress] = useState(ip?.cidr || ''); + const [description, setDescription] = useState(ip?.description || ''); + const [validationError, setValidationError] = useState(''); + + useEffect(() => { + setIpAddress(ip?.cidr || ''); + setDescription(ip?.description || ''); + setValidationError(''); + }, [ip, show]); + + const handleSave = () => { + // Clear previous errors + setValidationError(''); + + // Validate IP address + const ipValidation = validateIPAddress(ipAddress); + if (!ipValidation.isValid) { + setValidationError(ipValidation.error); + return; + } + + // If validation passes, save + onSave({ id: ip?.id, ipAddress, description }); + }; + + return ( + + + + + + + + + + setIpAddress(e.target.value)} + placeholder="e.g., 192.168.1.1/32 or 192.168.1.0/24" + /> + {validationError && ( + + + + )} + + + + setDescription(e.target.value)} + placeholder="(Optional) Enter description" + /> + + + + + + + + ); +} + +/** + * Delete confirmation dialog for IP ranges + */ +export function DeleteConfirm({ show, ip, onDelete, onClose, loading = false }) { + return ( + onDelete(ip)} + titleId="ipManager.deleteTitle" + loading={loading} + cancelId="ipManager.cancel" + confirmId="ipManager.deleteButton" + variant="danger" + > + {ip?.cidr}? + + ); +} + diff --git a/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx b/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx new file mode 100644 index 00000000000..eeeda55817c --- /dev/null +++ b/web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx @@ -0,0 +1,107 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import React from 'react'; +import expect from 'expect'; +import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-dom/test-utils'; + +import IPDialog, { DeleteConfirm } from '../IPDialog'; + +describe('IPDialog component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should call onSave with valid CIDR data when save clicked', () => { + const onSave = expect.createSpy(); + const onClose = expect.createSpy(); + + ReactDOM.render( + , + document.getElementById("container") + ); + + const ipInput = document.querySelectorAll('input[type="text"]')[0]; + const descInput = document.querySelectorAll('input[type="text"]')[1]; + + ipInput.value = '192.168.1.0/24'; + ReactTestUtils.Simulate.change(ipInput); + + descInput.value = 'Office Network'; + ReactTestUtils.Simulate.change(descInput); + + const buttons = document.querySelectorAll('.modal-footer button'); + const saveButton = buttons[1]; + ReactTestUtils.Simulate.click(saveButton); + + expect(onSave).toHaveBeenCalled(); + expect(onSave.calls[0].arguments[0].ipAddress).toBe('192.168.1.0/24'); + expect(onSave.calls[0].arguments[0].description).toBe('Office Network'); + }); + + it('should show validation error for invalid IP', () => { + const onSave = expect.createSpy(); + const onClose = expect.createSpy(); + + ReactDOM.render( + , + document.getElementById("container") + ); + + const ipInput = document.querySelectorAll('input[type="text"]')[0]; + ipInput.value = '192.168.1.1'; // No CIDR mask + ReactTestUtils.Simulate.change(ipInput); + + const buttons = document.querySelectorAll('.modal-footer button'); + const saveButton = buttons[1]; + ReactTestUtils.Simulate.click(saveButton); + + const errorBlock = document.querySelector('.help-block'); + expect(errorBlock).toExist(); + expect(onSave).toNotHaveBeenCalled(); + }); +}); + +describe('DeleteConfirm component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should call onDelete when confirmed', () => { + const onDelete = expect.createSpy(); + const onClose = expect.createSpy(); + const ip = { id: 1, cidr: '192.168.1.0/24' }; + + ReactDOM.render( + , + document.getElementById("container") + ); + + const buttons = document.querySelectorAll('button'); + const deleteButton = buttons[buttons.length - 1]; + ReactTestUtils.Simulate.click(deleteButton); + + expect(onDelete).toHaveBeenCalled(); + expect(onDelete.calls[0].arguments[0]).toBe(ip); + }); +}); + diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 7dcc0277c80..905f2f20dfa 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -1239,6 +1239,7 @@ "UserManager", "GroupManager", "TagsManager", + "IPManager", "Footer", { "name": "About" } ] diff --git a/web/client/plugins/Login.jsx b/web/client/plugins/Login.jsx index 2205937c0ef..967c95ab1fc 100644 --- a/web/client/plugins/Login.jsx +++ b/web/client/plugins/Login.jsx @@ -215,6 +215,13 @@ LoginPlugin.defaultProps = { path: '/manager/tagsmanager', position: 3 }, + { + name: 'resourcesCatalog.manageIPs', + msgId: 'resourcesCatalog.manageIPs', + glyph: 'globe', + path: '/manager/ipmanager', + position: 4 + }, { name: 'rulesmanager.menutitle', msgId: 'rulesmanager.menutitle', diff --git a/web/client/plugins/ResourcesCatalog/IPManager.jsx b/web/client/plugins/ResourcesCatalog/IPManager.jsx new file mode 100644 index 00000000000..54d50a04fce --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/IPManager.jsx @@ -0,0 +1,297 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { connect } from 'react-redux'; +import { createPlugin } from '../../utils/PluginsUtils'; +import { castArray } from 'lodash'; +import ConnectedResourcesGrid from './containers/ResourcesGrid'; +import GeoStoreDAO from '../../api/GeoStoreDAO'; +import IPDialog, { DeleteConfirm } from '../../components/manager/ipmanager/IPDialog'; +import { NewIP, EditIP, DeleteIP, IPFilter } from '../../components/manager/ipmanager/IPActions'; +import { show } from '../../actions/notifications'; + +const pageSize = 16; +function IPManager({ onNotification }) { + const [searchQuery, setSearchQuery] = useState({ q: '' }); + const [editingIP, setEditingIP] = useState(null); + const [showDialog, setShowDialog] = useState(false); + const [deletingIP, setDeletingIP] = useState(null); + const [showDelete, setShowDelete] = useState(false); + const [refreshFlag, setRefreshFlag] = useState(0); // to trigger refresh after CRUD + const [allIPRanges, setAllIPRanges] = useState([]); // Store all IPs + const [loading, setLoading] = useState(false); + const [isFirstRequest, setIsFirstRequest] = useState(true); + const [page, setPage] = useState(1); + const [savingIP, setSavingIP] = useState(false); // Loading state for save and delete operations + + // Fetch all data once on mount or refresh + useEffect(() => { + setLoading(true); + GeoStoreDAO.getIPRanges() + .then((response) => { + const ipRanges = castArray(response?.IPRangeList?.IPRange || []); + setAllIPRanges(ipRanges); + setIsFirstRequest(false); + }) + .catch((error) => { + console.error('Error fetching IP ranges:', error); + setAllIPRanges([]); + }) + .finally(() => { + setLoading(false); + }); + }, [refreshFlag]); + + // Client-side filtering and pagination (computed with useMemo to prevent re-renders) + const filteredIPRanges = useMemo(() => { + const q = searchQuery?.q; + if (!q) return allIPRanges; + const lowerQ = q.toLowerCase(); + return allIPRanges.filter(ip => + ip.cidr?.toLowerCase().includes(lowerQ) || + ip.description?.toLowerCase().includes(lowerQ) + ); + }, [allIPRanges, searchQuery.q]); + + const totalResources = filteredIPRanges.length; + + const resources = useMemo(() => { + const start = (page - 1) * pageSize; + const end = start + pageSize; + return filteredIPRanges.slice(start, end); + }, [filteredIPRanges, page, pageSize]); + + // Handler to update search and reset page to 1 (memoized to prevent re-renders) + const handleSearchChange = useCallback((newSearch) => { + setSearchQuery(newSearch); + setPage(1); + }, []); + + const handleSave = useCallback((ip) => { + const payload = { + cidr: ip.ipAddress, + description: ip.description || "" + }; + + setSavingIP(true); + + if (ip.id) { + GeoStoreDAO.updateIPRange(ip.id, payload) + .then(() => { + setShowDialog(false); + setEditingIP(null); + setPage(1); + setRefreshFlag(f => f + 1); + onNotification({ + title: 'ipManager.notification.updateSuccessTitle', + message: 'ipManager.notification.updateSuccessMessage', + autoDismiss: 6, + position: 'tr' + }, 'success'); + }) + .catch((error) => { + console.error('Error updating IP range:', error); + onNotification({ + title: 'ipManager.notification.updateErrorTitle', + message: 'ipManager.notification.updateErrorMessage', + autoDismiss: 6, + position: 'tr' + }, 'error'); + }) + .finally(() => { + setSavingIP(false); + }); + } else { + GeoStoreDAO.createIPRange(payload) + .then(() => { + setShowDialog(false); + setEditingIP(null); + setPage(1); + setRefreshFlag(f => f + 1); + onNotification({ + title: 'ipManager.notification.createSuccessTitle', + message: 'ipManager.notification.createSuccessMessage', + autoDismiss: 6, + position: 'tr' + }, 'success'); + }) + .catch((error) => { + console.error('Error creating IP range:', error); + onNotification({ + title: 'ipManager.notification.createErrorTitle', + message: 'ipManager.notification.createErrorMessage', + autoDismiss: 6, + position: 'tr' + }, 'error'); + }) + .finally(() => { + setSavingIP(false); + }); + } + }, [onNotification]); + + const handleDelete = useCallback((ip) => { + setSavingIP(true); + + GeoStoreDAO.deleteIPRange(ip.id) + .then(() => { + setShowDelete(false); + setDeletingIP(null); + setPage(1); + setRefreshFlag(f => f + 1); + onNotification({ + title: 'ipManager.notification.deleteSuccessTitle', + message: 'ipManager.notification.deleteSuccessMessage', + autoDismiss: 6, + position: 'tr' + }, 'success'); + }) + .catch((error) => { + console.error('Error deleting IP range:', error); + onNotification({ + title: 'ipManager.notification.deleteErrorTitle', + message: 'ipManager.notification.deleteErrorMessage', + autoDismiss: 6, + position: 'tr' + }, 'error'); + }) + .finally(() => { + setSavingIP(false); + }); + }, [onNotification]); + + // Metadata for IP cards + const metadata = [ + { + path: 'cidr', + target: 'header', + showFullContent: true, + icon: { glyph: 'globe' } + }, + { + path: 'description', + showFullContent: true, + target: 'footer' + } + ]; + + // Handle pagination clicks from ResourcesGrid (memoized) + const handleResourcesRequest = useCallback(({ params }) => { + // Update page if it changed + if (params?.page !== undefined && params.page !== page) { + setPage(params.page); + } + // Return resolved promise immediately (data is already computed above) + return Promise.resolve({ + total: totalResources, + resources: resources, + isNextPageAvailable: page < Math.ceil(totalResources / pageSize) + }); + }, [page, totalResources, resources, pageSize]); + + // Handlers for UI actions (memoized) + const handleEditIP = useCallback((ip) => { + setEditingIP(ip); + setShowDialog(true); + }, []); + + const handleDeleteIP = useCallback((ip) => { + setDeletingIP(ip); + setShowDelete(true); + }, []); + + const handleNewIP = useCallback(() => { + setEditingIP(null); + setShowDialog(true); + }, []); + + const handleResetSearch = useCallback(() => { + setSearchQuery({ q: '' }); + setPage(1); + }, []); + + const handleCloseDialog = useCallback(() => { + setShowDialog(false); + setEditingIP(null); + }, []); + + const handleCloseDeleteDialog = useCallback(() => { + setShowDelete(false); + setDeletingIP(null); + }, []); + + + const configuredItems = useMemo(() => [ + { Component: (props) => , target: 'card-buttons', name: 'editip' }, + { Component: (props) => , target: 'card-buttons', name: 'deleteip' }, + { Component: (props) => handleSearchChange(params)} />, target: 'left-menu', name: 'ipfilter' }, + { Component: (props) => , target: 'right-menu', name: 'newip' } + ], [handleSearchChange, handleEditIP, handleDeleteIP, handleNewIP]); + + return ( +
+ {}} + setResources={() => {}} + setResourcesMetadata={() => {}} + /> + + +
+ ); +} + +const ConnectedIPManager = connect( + null, + { + onNotification: show + } +)(IPManager); + +export default createPlugin('IPManager', { + component: ConnectedIPManager, + containers: { + Manager: { + name: 'ipmanager', + position: 4, + priority: 2, + glyph: '1-ip-mod', + labelId: 'messages.manager.ipmanagertab' + } + }, + epics: {}, + reducers: {} +}); diff --git a/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx b/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx index 6b8d94e8636..cfbea0cee5c 100644 --- a/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx +++ b/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx @@ -18,6 +18,7 @@ import Text from '../../../components/layout/Text'; function PermissionsRow({ type, name, + description, options, hideOptions, hideIcon, @@ -47,9 +48,16 @@ function PermissionsRow({ {(!hideIcon && (type || avatar)) && {avatar ? - : } + : } } - {name} + + {name} + {description && type === 'ip' && ( + + {description} + + )} + {children} diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx index 5631a308141..8ad0401cf8d 100644 --- a/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx +++ b/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx @@ -20,6 +20,7 @@ import Text from '../../../components/layout/Text'; import Message from '../../../components/I18N/Message'; import useIsMounted from '../../../hooks/useIsMounted'; import Spinner from '../../../components/layout/Spinner'; +import useIPRanges from '../hooks/useIPRanges'; function ResourcePermissions({ editing, @@ -31,6 +32,8 @@ function ResourcePermissions({ const init = useRef(false); const isMounted = useIsMounted(); + const { request: ipRequest } = useIPRanges(); + useEffect(() => { if (resource?.permissions === undefined && !init.current) { init.current = true; @@ -57,6 +60,16 @@ function ResourcePermissions({ permissions: entry?.canWrite ? 'edit' : 'view' }; } + if (entry?.ipRanges && entry.ipRanges !== '') { + const ipRange = entry.ipRanges.ipRange; + return { + type: 'ip', + id: ipRange.id, + name: ipRange.cidr, + description: ipRange.description || '', + permissions: entry?.canWrite ? 'edit' : 'view' + }; + } return { type: 'user', id: entry?.user?.id, @@ -66,9 +79,9 @@ function ResourcePermissions({ }; }); - const groupsPermissions = resource?.permissions?.some(entry => !!entry.group); + const groupsOrIpPermissions = resource?.permissions?.some(entry => !!entry.group || !!entry.ipRanges); - if (!editing && !groupsPermissions) { + if (!editing && !groupsOrIpPermissions) { return (
@@ -104,6 +117,18 @@ function ResourcePermissions({ onChange={({ entries }) => { const userPermissions = (entries || []).filter((entry) => entry.type === 'user').map(entry => entry.originalEntry); + const ipPermissions = entries.filter((entry) => entry.type === 'ip').map((entry) => ({ + canRead: ['view', 'edit'].includes(entry.permissions), + canWrite: ['edit'].includes(entry.permissions), + ipRanges: { + ipRange: { + id: entry.id, + cidr: entry.name, + description: entry.description + } + } + })); + onChange({ 'permissions': [ ...entries.filter((entry) => entry.type === 'group').map((entry) => { @@ -116,6 +141,7 @@ function ResourcePermissions({ } }; }), + ...ipPermissions, ...userPermissions ] }); @@ -179,6 +205,24 @@ function ResourcePermissions({ }; }); } + }, + { + id: 'ip', + labelId: 'resourcesCatalog.ip', + request: ipRequest, + responseToEntries: ({ response, entries }) => { + return response.ips.map((ip) => { + const permissions = (entries || []).find(entry => entry.id === ip.id)?.permissions; + return { + type: 'ip', + id: ip.id, + name: ip.cidr, + description: ip.description, + permissions, + parsed: true + }; + }); + } } ]} /> diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIPRanges-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIPRanges-test.js new file mode 100644 index 00000000000..7cc50fac52d --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIPRanges-test.js @@ -0,0 +1,97 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import useIPRanges from '../useIPRanges'; +import expect from 'expect'; +import { act, Simulate } from 'react-dom/test-utils'; +import GeoStoreDAO from '../../../../api/GeoStoreDAO'; + +const Component = ({ onLoaded, query = '', page = 1, pageSize = 10 }) => { + const { request } = useIPRanges(); + return ( +
+ +
+ ); +}; + +describe('useIPRanges', () => { + let getIPRangesSpy; + + beforeEach((done) => { + document.body.innerHTML = '
'; + getIPRangesSpy = expect.spyOn(GeoStoreDAO, 'getIPRanges'); + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + if (getIPRangesSpy) { + getIPRangesSpy.restore(); + } + setTimeout(done); + }); + + it('should fetch IP ranges on first request call', (done) => { + const mockIPRanges = { + IPRangeList: { + IPRange: [ + { cidr: '192.168.1.0/24', description: 'Test Range 1' }, + { cidr: '10.0.0.0/8', description: 'Test Range 2' } + ] + } + }; + + getIPRangesSpy.andReturn(Promise.resolve(mockIPRanges)); + + act(() => { + ReactDOM.render( { + expect(result.ips.length).toBe(2); + expect(result.ips[0].cidr).toBe('192.168.1.0/24'); + expect(getIPRangesSpy.calls.length).toBe(1); + done(); + }} />, document.getElementById("container")); + }); + + Simulate.click(document.querySelector('#fetch')); + }); + + it('should filter IP ranges by search query', (done) => { + const mockIPRanges = { + IPRangeList: { + IPRange: [ + { cidr: '192.168.1.0/24', description: 'Office Network' }, + { cidr: '10.0.0.0/8', description: 'VPN Range' }, + { cidr: '172.16.0.0/12', description: 'Office Backup' } + ] + } + }; + + getIPRangesSpy.andReturn(Promise.resolve(mockIPRanges)); + + act(() => { + ReactDOM.render( { + expect(result.ips.length).toBe(2); + expect(result.ips[0].cidr).toBe('192.168.1.0/24'); + expect(result.ips[1].cidr).toBe('172.16.0.0/12'); + done(); + }} + />, document.getElementById("container")); + }); + + Simulate.click(document.querySelector('#fetch')); + }); + +}); diff --git a/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js b/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js new file mode 100644 index 00000000000..246a0081d4c --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/hooks/useIPRanges.js @@ -0,0 +1,129 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useState, useRef, useCallback } from 'react'; +import GeoStoreDAO from '../../../api/GeoStoreDAO'; +import { castArray } from 'lodash'; + +/** + * Custom hook to manage IP ranges fetching and caching + * + * Provides: + * - request: Function for PermissionsAddEntriesPanel + * - isLoading: Loading state + * - error: Error state + * - refresh: Function to clear cache and refetch + * + * @returns {Object} Hook API + */ +const useIPRanges = () => { + const [allIPRanges, setAllIPRanges] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const ipRangesFetched = useRef(false); + const ipRangesFetchPromise = useRef(null); + + /** + * Fetches IP ranges from API (lazy fetch on first call) + * @returns {Promise} Array of IP ranges + */ + const fetchIPRanges = useCallback(() => { + if (ipRangesFetched.current) { + // Already fetched, return resolved promise with cached data + return Promise.resolve(allIPRanges); + } + + if (ipRangesFetchPromise.current) { + // Fetch already in progress, return the same promise + return ipRangesFetchPromise.current; + } + + // Start fetching + ipRangesFetched.current = true; + setIsLoading(true); + setError(null); + + ipRangesFetchPromise.current = GeoStoreDAO.getIPRanges() + .then((response) => { + const ipRanges = castArray(response?.IPRangeList?.IPRange || []); + setAllIPRanges(ipRanges); + setIsLoading(false); + return ipRanges; + }) + .catch((err) => { + console.error('Error fetching IP ranges:', err); + setError(err); + setIsLoading(false); + ipRangesFetched.current = false; // Reset on error to allow retry + ipRangesFetchPromise.current = null; + return []; + }) + .finally(() => { + ipRangesFetchPromise.current = null; + }); + + return ipRangesFetchPromise.current; + }, [allIPRanges]); + + /** + * Request function for PermissionsAddEntriesPanel + * Handles filtering, pagination, and formatting + */ + const request = useCallback(({ q, page: pageParam, pageSize }) => { + // Fetch IP ranges on first call (when IP tab is opened) + return fetchIPRanges().then((fetchedIPRanges) => { + const page = pageParam - 1; + let ipRanges = [...fetchedIPRanges]; + + // Client-side filtering + if (q) { + const lowerQ = q.toLowerCase(); + ipRanges = ipRanges.filter(ip => + ip.cidr?.toLowerCase().includes(lowerQ) || + ip.description?.toLowerCase().includes(lowerQ) + ); + } + + // Client-side pagination + const start = page * pageSize; + const end = start + pageSize; + const paginatedRanges = ipRanges.slice(start, end); + + // Return paginated results with formatted labels + return { + ips: paginatedRanges.map((ip) => ({ + ...ip, + filterValue: ip.cidr, + value: ip.cidr + })), + isNextPageAvailable: end < ipRanges.length + }; + }); + }, [fetchIPRanges]); + + /** + * Clears cache and re-fetches IP ranges + */ + const refresh = useCallback(() => { + ipRangesFetched.current = false; + ipRangesFetchPromise.current = null; + setAllIPRanges([]); + setError(null); + fetchIPRanges(); + }, [fetchIPRanges]); + + return { + request, + isLoading, + error, + refresh + }; +}; + +export default useIPRanges; + diff --git a/web/client/plugins/__tests__/Login-test.js b/web/client/plugins/__tests__/Login-test.js index 5e2c2b8cd52..45adc071dd2 100644 --- a/web/client/plugins/__tests__/Login-test.js +++ b/web/client/plugins/__tests__/Login-test.js @@ -145,8 +145,8 @@ describe('Login Plugin', () => { ReactDOM.render(, document.getElementById("container")); expect(document.querySelector('#mapstore-login-menu .glyphicon-user')).toBeTruthy(); const entries = document.querySelectorAll("#mapstore-login-menu ~ ul li[role=\"presentation\"]"); - expect(entries.length).toEqual(6); - expect([...entries].map(entry => entry.innerText)).toEqual(['user.info', 'user.changePwd', 'users.title', 'usergroups.title', 'resourcesCatalog.manageTags', 'user.logout']); + expect(entries.length).toEqual(7); + expect([...entries].map(entry => entry.innerText)).toEqual(['user.info', 'user.changePwd', 'users.title', 'usergroups.title', 'resourcesCatalog.manageTags', 'resourcesCatalog.manageIPs', 'user.logout']); }); it('test show change password in case ms user ', () => { const storeState = stateMocker(toggleControl('LoginForm', 'enabled'), loginSuccess({ User: { name: "Test", access_token: "some-token", role: 'USER' }}) ); diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js index a48a29f3344..792ea2c5336 100644 --- a/web/client/product/plugins.js +++ b/web/client/product/plugins.js @@ -143,7 +143,8 @@ export const plugins = { ZoomInPlugin: toModulePlugin('ZoomIn', () => import(/* webpackChunkName: 'plugins/zoomIn' */ '../plugins/ZoomIn')), ZoomOutPlugin: toModulePlugin('ZoomOut', () => import(/* webpackChunkName: 'plugins/zoomOut' */ '../plugins/ZoomOut')), AddWidgetDashboardPlugin: toModulePlugin('AddWidgetDashboard', () => import(/* webpackChunkName: 'plugins/AddWidgetDashboard' */ '../plugins/AddWidgetDashboard')), - MapConnectionDashboardPlugin: toModulePlugin('MapConnectionDashboard', () => import(/* webpackChunkName: 'plugins/MapConnectionDashboard' */ '../plugins/MapConnectionDashboard')) + MapConnectionDashboardPlugin: toModulePlugin('MapConnectionDashboard', () => import(/* webpackChunkName: 'plugins/MapConnectionDashboard' */ '../plugins/MapConnectionDashboard')), + IPManagerPlugin: toModulePlugin('IPManager', () => import(/* webpackChunkName: 'plugins/IPManager' */ '../plugins/ResourcesCatalog/IPManager')) }; const pluginsDefinition = { diff --git a/web/client/themes/default/less/resources-catalog/_permissions.less b/web/client/themes/default/less/resources-catalog/_permissions.less index 9ccf8ccd37e..c53afc7cb4b 100644 --- a/web/client/themes/default/less/resources-catalog/_permissions.less +++ b/web/client/themes/default/less/resources-catalog/_permissions.less @@ -20,4 +20,9 @@ width: 100%; } } + .ms-permission-description { + font-size: 11px; + opacity: 0.6; + word-break: break-word; + } } \ No newline at end of file diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json index f5fee24a7d3..a21e04ddaff 100644 --- a/web/client/translations/data.de-DE.json +++ b/web/client/translations/data.de-DE.json @@ -460,7 +460,8 @@ "featuredMaps": "Empfohlene Karten", "groupmanagerTab": "Gruppen", "usermanagerTab": "Benutzer", - "tagsmanagerTab": "Schlagwörter" + "tagsmanagerTab": "Schlagwörter", + "ipmanagerTab": "IP-Bereiche" }, "newMap": "Neue Karte", "newMapEmpty": "Leere Karte", @@ -4454,6 +4455,10 @@ "noContentYetTitle": "Keine Gruppen vorhanden", "noContentYetContent": "Es sind keine Benutzergruppen vorhanden. Klicken Sie auf \"Neue Gruppe\", um eine zu erstellen" }, + "ipmanagerSection": { + "noContentYetTitle": "Noch keine IP-Bereiche registriert", + "noContentYetContent": " " + }, "mapsFilter": "Karten", "dashboardsFilter": "Dashboards", "geostoriesFilter": "Geostories", @@ -4480,6 +4485,7 @@ "editPermission": "Bearbeiten", "ownerPermission": "Eigentümer", "groups": "Gruppen", + "ip": "IP-Bereiche", "filterBy": "Filtern...", "about": "Über", "readMore": "Mehr lesen", @@ -4566,6 +4572,7 @@ "tagDescription": "Beschreibung", "tagColor": "Farbe", "manageTags": "Tags Verwalten", + "manageIPs": "IP-Bereiche verwalten", "errorLoadingTags": "Es ist nicht möglich, Tags zu laden", "errorUpdatingTag": "Es ist nicht möglich, das Tag zu aktualisieren", "errorTagNameAlreadyExist": "Das Tag, das Sie erstellen möchten, existiert bereits", @@ -4577,6 +4584,42 @@ "filterApplied": "Filter angewendet", "emptyFilterItems": "Keine Elemente zum Anzeigen" }, + "ipManager": { + "newIP": "Neuer IP-Bereich", + "editTitle": "IP-Bereich bearbeiten", + "editTooltip": "IP-Bereich bearbeiten", + "deleteTitle": "IP-Bereich löschen", + "deleteTooltip": "IP-Bereich löschen", + "deleteButton": "Löschen", + "deleteConfirm": "Möchten Sie diesen IP-Bereich wirklich löschen?", + "ipAddress": "IP-Bereich (CIDR-Format)", + "description": "Beschreibung", + "search": "IP-Bereiche durchsuchen...", + "save": "Speichern", + "cancel": "Abbrechen", + "ipsFound": "{count, plural, =0 {0 IP-Bereiche gefunden} =1 {1 IP-Bereich gefunden} other {# IP-Bereiche gefunden}}", + "validation": { + "ipRequired": "IP-Bereich ist erforderlich", + "cidrRequired": "CIDR-Notation erforderlich (z.B. 192.168.1.1/32 oder 192.168.1.0/24)", + "invalidMask": "Subnetzmaske muss zwischen 0 und 32 liegen", + "invalidFormat": "Ungültiges IP-Bereichsformat", + "invalidOctet": "Jedes Oktett muss zwischen 0 und 255 liegen" + }, + "notification": { + "createSuccessTitle": "Erfolg", + "createSuccessMessage": "IP-Bereich erfolgreich erstellt", + "createErrorTitle": "Fehler", + "createErrorMessage": "IP-Bereich konnte nicht erstellt werden", + "updateSuccessTitle": "Erfolg", + "updateSuccessMessage": "IP-Bereich erfolgreich aktualisiert", + "updateErrorTitle": "Fehler", + "updateErrorMessage": "IP-Bereich konnte nicht aktualisiert werden", + "deleteSuccessTitle": "Erfolg", + "deleteSuccessMessage": "IP-Bereich erfolgreich gelöscht", + "deleteErrorTitle": "Fehler", + "deleteErrorMessage": "IP-Bereich konnte nicht gelöscht werden" + } + }, "itinerary": { "title": "Reiseroute", "tooltip": "Reiseroute", diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json index e2c7f5fa757..c4a0b6516ce 100644 --- a/web/client/translations/data.en-US.json +++ b/web/client/translations/data.en-US.json @@ -422,7 +422,8 @@ "featuredMaps": "Featured", "groupmanagerTab": "Groups", "usermanagerTab": "Users", - "tagsmanagerTab": "Tags" + "tagsmanagerTab": "Tags", + "ipmanagerTab": "IP Ranges" }, "newMap": "New map", "newMapEmpty": "Empty map", @@ -4421,6 +4422,10 @@ "noPublicContentTitle": "Featured Resources", "noPublicContentContent": "This catalog doesn't have featured resources." }, + "ipmanagerSection": { + "noContentYetTitle": "No IP ranges registered yet", + "noContentYetContent": " " + }, "groupsSection": { "noContentYetTitle": "No user groups", "noContentYetContent": "There are no user groups. Click on \"New Group\" to create one" @@ -4451,6 +4456,7 @@ "editPermission": "Edit", "ownerPermission": "Owner", "groups": "Groups", + "ip": "IP Ranges", "filterBy": "Filter...", "about": "About", "readMore": "Read more", @@ -4537,6 +4543,7 @@ "tagDescription": "Description", "tagColor": "Color", "manageTags": "Manage Tags", + "manageIPs": "Manage IP Ranges", "errorLoadingTags": "It is not possible to load tags", "errorUpdatingTag": "It is not possible to update the tag", "errorTagNameAlreadyExist": "The tag you are trying to create already exists", @@ -4548,6 +4555,42 @@ "filterApplied": "Filter applied", "emptyFilterItems": "No items to display" }, + "ipManager": { + "newIP": "New IP range", + "editTitle": "Edit IP range", + "editTooltip": "Edit IP range", + "deleteTitle": "Delete IP range", + "deleteTooltip": "Delete IP range", + "deleteButton": "Delete", + "deleteConfirm": "Are you sure you want to delete this IP range?", + "ipAddress": "IP range (CIDR format)", + "description": "Description", + "search": "Search IP ranges...", + "save": "Save", + "cancel": "Cancel", + "ipsFound": "{count, plural, =0 {0 IP ranges found} =1 {1 IP range found} other {# IP ranges found}}", + "validation": { + "ipRequired": "IP range is required", + "cidrRequired": "CIDR notation required (e.g., 192.168.1.1/32 or 192.168.1.0/24)", + "invalidMask": "Subnet mask must be between 0 and 32", + "invalidFormat": "Invalid IP range format", + "invalidOctet": "Each octet must be between 0 and 255" + }, + "notification": { + "createSuccessTitle": "Success", + "createSuccessMessage": "IP range created successfully", + "createErrorTitle": "Error", + "createErrorMessage": "Failed to create IP range", + "updateSuccessTitle": "Success", + "updateSuccessMessage": "IP range updated successfully", + "updateErrorTitle": "Error", + "updateErrorMessage": "Failed to update IP range", + "deleteSuccessTitle": "Success", + "deleteSuccessMessage": "IP range deleted successfully", + "deleteErrorTitle": "Error", + "deleteErrorMessage": "Failed to delete IP range" + } + }, "itinerary": { "title": "Itinerary", "tooltip": "Itinerary", diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json index b6e377d69cb..e5b227b9f9f 100644 --- a/web/client/translations/data.es-ES.json +++ b/web/client/translations/data.es-ES.json @@ -422,7 +422,8 @@ "featuredMaps": "Mapas Destacados", "groupmanagerTab": "Grupos", "usermanagerTab": "Usuarios", - "tagsmanagerTab": "Etiquetas" + "tagsmanagerTab": "Etiquetas", + "ipmanagerTab": "Rangos de IP" }, "newMap": "Nuevo mapa", "newMapEmpty": "Mapa vacío", @@ -4415,6 +4416,10 @@ "noContentYetTitle": "No hay grupos", "noContentYetContent": "No hay grupos de usuarios. Haz clic en \"Nuevo grupo\" para crear uno" }, + "ipmanagerSection": { + "noContentYetTitle": "Aún no hay rangos de IP registrados", + "noContentYetContent": " " + }, "mapsFilter": "Mapas", "dashboardsFilter": "Paneles de control", "geostoriesFilter": "Geostories", @@ -4441,6 +4446,7 @@ "editPermission": "Editar", "ownerPermission": "Propietario", "groups": "Grupos", + "ip": "Rangos de IP", "filterBy": "Filtrar...", "about": "Acerca de", "readMore": "Leer más", @@ -4527,6 +4533,7 @@ "tagDescription": "Descripción", "tagColor": "Color", "manageTags": "Administrar Etiquetas", + "manageIPs": "Administrar Rangos de IP", "errorLoadingTags": "No es posible cargar etiquetas", "errorUpdatingTag": "No es posible actualizar la etiqueta", "errorTagNameAlreadyExist": "La etiqueta que estás intentando crear ya existe", @@ -4538,6 +4545,42 @@ "filterApplied": "Filtro aplicado", "emptyFilterItems": "No hay elementos para mostrar" }, + "ipManager": { + "newIP": "Nuevo rango de IP", + "editTitle": "Editar rango de IP", + "editTooltip": "Editar rango de IP", + "deleteTitle": "Eliminar rango de IP", + "deleteTooltip": "Eliminar rango de IP", + "deleteButton": "Eliminar", + "deleteConfirm": "¿Está seguro de que desea eliminar este rango de IP?", + "ipAddress": "Rango de IP (formato CIDR)", + "description": "Descripción", + "search": "Buscar rangos de IP...", + "save": "Guardar", + "cancel": "Cancelar", + "ipsFound": "{count, plural, =0 {0 rangos de IP encontrados} =1 {1 rango de IP encontrado} other {# rangos de IP encontrados}}", + "validation": { + "ipRequired": "El rango de IP es obligatorio", + "cidrRequired": "Se requiere notación CIDR (ej., 192.168.1.1/32 o 192.168.1.0/24)", + "invalidMask": "La máscara de subred debe estar entre 0 y 32", + "invalidFormat": "Formato de rango de IP inválido", + "invalidOctet": "Cada octeto debe estar entre 0 y 255" + }, + "notification": { + "createSuccessTitle": "Éxito", + "createSuccessMessage": "Rango de IP creado correctamente", + "createErrorTitle": "Error", + "createErrorMessage": "Error al crear el rango de IP", + "updateSuccessTitle": "Éxito", + "updateSuccessMessage": "Rango de IP actualizado correctamente", + "updateErrorTitle": "Error", + "updateErrorMessage": "Error al actualizar el rango de IP", + "deleteSuccessTitle": "Éxito", + "deleteSuccessMessage": "Rango de IP eliminado correctamente", + "deleteErrorTitle": "Error", + "deleteErrorMessage": "Error al eliminar el rango de IP" + } + }, "itinerary": { "title": "Itinerario", "tooltip": "Itinerario", diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json index eb700b6fea9..55c6d680e61 100644 --- a/web/client/translations/data.fr-FR.json +++ b/web/client/translations/data.fr-FR.json @@ -422,7 +422,8 @@ "featuredMaps": "Cartes à la une", "groupmanagerTab": "Groupes", "usermanagerTab": "Utilisateurs", - "tagsmanagerTab": "Balises" + "tagsmanagerTab": "Balises", + "ipmanagerTab": "Plages IP" }, "newMap": "Nouvelle carte", "newMapEmpty": "Carte vide", @@ -4416,6 +4417,10 @@ "noContentYetTitle": "Aucun groupe trouvé", "noContentYetContent": "Il n'y a pas de groupes d'utilisateurs. Cliquez sur « Nouveau groupe » pour en créer un." }, + "ipmanagerSection": { + "noContentYetTitle": "Aucune plage IP enregistrée pour le moment", + "noContentYetContent": " " + }, "mapsFilter": "Cartes", "dashboardsFilter": "Tableaux de bord", "geostoriesFilter": "Géostories", @@ -4442,6 +4447,7 @@ "editPermission": "Modifier", "ownerPermission": "Propriétaire", "groups": "Groupes", + "ip": "Plages IP", "filterBy": "Filtrer...", "about": "À propos", "readMore": "Lire la suite", @@ -4528,6 +4534,7 @@ "tagDescription": "Description", "tagColor": "Couleur", "manageTags": "Gérer les Tags", + "manageIPs": "Gérer les Plages IP", "errorLoadingTags": "Il n'est pas possible de charger des balises", "errorUpdatingTag": "Il n'est pas possible de mettre à jour la balise", "errorTagNameAlreadyExist": "La balise que vous essayez de créer existe déjà", @@ -4539,6 +4546,42 @@ "filterApplied": "Filtre appliqué", "emptyFilterItems": "Aucun élément à afficher" }, + "ipManager": { + "newIP": "Nouvelle Plage IP", + "editTitle": "Modifier la plage IP", + "editTooltip": "Modifier la plage IP", + "deleteTitle": "Supprimer la plage IP", + "deleteTooltip": "Supprimer la plage IP", + "deleteButton": "Supprimer", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette plage IP ?", + "ipAddress": "Plage IP (format CIDR)", + "description": "Description", + "search": "Rechercher des plages IP...", + "save": "Enregistrer", + "cancel": "Annuler", + "ipsFound": "{count, plural, =0 {0 plage IP trouvée} =1 {1 plage IP trouvée} other {# plages IP trouvées}}", + "validation": { + "ipRequired": "La plage IP est obligatoire", + "cidrRequired": "Notation CIDR requise (ex., 192.168.1.1/32 ou 192.168.1.0/24)", + "invalidMask": "Le masque de sous-réseau doit être entre 0 et 32", + "invalidFormat": "Format de plage IP invalide", + "invalidOctet": "Chaque octet doit être entre 0 et 255" + }, + "notification": { + "createSuccessTitle": "Succès", + "createSuccessMessage": "Plage IP créée avec succès", + "createErrorTitle": "Erreur", + "createErrorMessage": "Échec de la création de la plage IP", + "updateSuccessTitle": "Succès", + "updateSuccessMessage": "Plage IP mise à jour avec succès", + "updateErrorTitle": "Erreur", + "updateErrorMessage": "Échec de la mise à jour de la plage IP", + "deleteSuccessTitle": "Succès", + "deleteSuccessMessage": "Plage IP supprimée avec succès", + "deleteErrorTitle": "Erreur", + "deleteErrorMessage": "Échec de la suppression de la plage IP" + } + }, "itinerary": { "title": "Itinéraire", "tooltip": "Itinéraire", diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json index 7d65b98b056..1819516a4a6 100644 --- a/web/client/translations/data.it-IT.json +++ b/web/client/translations/data.it-IT.json @@ -422,7 +422,8 @@ "featuredMaps": "In Evidenza", "groupmanagerTab": "Gruppi", "usermanagerTab": "Utenti", - "tagsmanagerTab": "Tags" + "tagsmanagerTab": "Tags", + "ipmanagerTab": "Intervalli IP" }, "newMap": "Nuova Mappa", "newMapEmpty": "Mappa vuota", @@ -4417,6 +4418,10 @@ "noContentYetTitle": "Non ci sono gruppi", "noContentYetContent": "Non ci sono gruppi utenti. Clicca su \"Nuovo Gruppo\" per crearne uno" }, + "ipmanagerSection": { + "noContentYetTitle": "Nessun intervallo IP registrato ancora", + "noContentYetContent": " " + }, "mapsFilter": "Mappe", "dashboardsFilter": "Dashboard", "geostoriesFilter": "Geostories", @@ -4443,6 +4448,7 @@ "editPermission": "Modifica", "ownerPermission": "Proprietario", "groups": "Gruppi", + "ip": "Intervalli IP", "filterBy": "Filtra...", "about": "Informazioni", "readMore": "Leggi di più", @@ -4529,6 +4535,7 @@ "tagDescription": "Descrizione", "tagColor": "Colore", "manageTags": "Gestisci Tag", + "manageIPs": "Gestisci Intervalli IP", "errorLoadingTags": "Impossibile caricare i tag", "errorUpdatingTag": "Impossibile aggiornare il tag", "errorTagNameAlreadyExist": "Il tag che stai tentando di creare esiste già", @@ -4540,6 +4547,42 @@ "filterApplied": "Filtro applicato", "emptyFilterItems": "Nessun elemento da visualizzare" }, + "ipManager": { + "newIP": "Nuovo intervallo IP", + "editTitle": "Modifica intervallo IP", + "editTooltip": "Modifica intervallo IP", + "deleteTitle": "Elimina intervallo IP", + "deleteTooltip": "Elimina intervallo IP", + "deleteButton": "Elimina", + "deleteConfirm": "Sei sicuro di voler eliminare questo intervallo IP?", + "ipAddress": "Intervallo IP (formato CIDR)", + "description": "Descrizione", + "search": "Cerca intervalli IP...", + "save": "Salva", + "cancel": "Annulla", + "ipsFound": "{count, plural, =0 {0 intervalli IP trovati} =1 {1 intervallo IP trovato} other {# intervalli IP trovati}}", + "validation": { + "ipRequired": "L'intervallo IP è obbligatorio", + "cidrRequired": "Notazione CIDR richiesta (es., 192.168.1.1/32 o 192.168.1.0/24)", + "invalidMask": "La maschera di sottorete deve essere compresa tra 0 e 32", + "invalidFormat": "Formato intervallo IP non valido", + "invalidOctet": "Ogni ottetto deve essere compreso tra 0 e 255" + }, + "notification": { + "createSuccessTitle": "Successo", + "createSuccessMessage": "Intervallo IP creato con successo", + "createErrorTitle": "Errore", + "createErrorMessage": "Impossibile creare l'intervallo IP", + "updateSuccessTitle": "Successo", + "updateSuccessMessage": "Intervallo IP aggiornato con successo", + "updateErrorTitle": "Errore", + "updateErrorMessage": "Impossibile aggiornare l'intervallo IP", + "deleteSuccessTitle": "Successo", + "deleteSuccessMessage": "Intervallo IP eliminato con successo", + "deleteErrorTitle": "Errore", + "deleteErrorMessage": "Impossibile eliminare l'intervallo IP" + } + }, "itinerary": { "title": "Itinerario", "tooltip": "Itinerario", diff --git a/web/client/utils/IPValidationUtils.js b/web/client/utils/IPValidationUtils.js new file mode 100644 index 00000000000..85daa9e7900 --- /dev/null +++ b/web/client/utils/IPValidationUtils.js @@ -0,0 +1,60 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Validates an IP address in CIDR notation (required format) + * @param {string} ipAddress - The IP address in CIDR notation (e.g., "192.168.1.1/32" or "192.168.1.0/24") + * @returns {object} - Returns { isValid: boolean, error: string|null } where error is a translation key + */ +export function validateIPAddress(ipAddress) { + if (!ipAddress || typeof ipAddress !== 'string') { + return { isValid: false, error: 'ipManager.validation.ipRequired' }; + } + + // Trim whitespace + const trimmedIP = ipAddress.trim(); + + if (!trimmedIP) { + return { isValid: false, error: 'ipManager.validation.ipRequired' }; + } + + // CIDR notation is required (IP/mask) + const parts = trimmedIP.split('/'); + + // Must have exactly 2 parts: IP and mask + if (parts.length !== 2) { + return { isValid: false, error: 'ipManager.validation.cidrRequired' }; + } + + // Validate CIDR notation: IP/mask + const ip = parts[0].trim(); + const maskStr = parts[1].trim(); + const mask = parseInt(maskStr, 10); + + // Validate mask + if (maskStr === '' || isNaN(mask) || mask < 0 || mask > 32) { + return { isValid: false, error: 'ipManager.validation.invalidMask' }; + } + + // Validate IP address format + const ipParts = ip.split('.'); + if (ipParts.length !== 4) { + return { isValid: false, error: 'ipManager.validation.invalidFormat' }; + } + + for (let i = 0; i < 4; i++) { + const octetStr = ipParts[i].trim(); + const octet = parseInt(octetStr, 10); + if (octetStr === '' || isNaN(octet) || octet < 0 || octet > 255) { + return { isValid: false, error: 'ipManager.validation.invalidOctet' }; + } + } + + return { isValid: true, error: null }; +} + diff --git a/web/client/utils/__tests__/IPValidationUtils-test.js b/web/client/utils/__tests__/IPValidationUtils-test.js new file mode 100644 index 00000000000..a74372a7ee5 --- /dev/null +++ b/web/client/utils/__tests__/IPValidationUtils-test.js @@ -0,0 +1,100 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import expect from 'expect'; + +import { validateIPAddress } from '../IPValidationUtils'; + +describe('IPValidationUtils test', () => { + describe('validateIPAddress - Valid CIDR notation', () => { + it('should accept valid CIDR formats', () => { + const validCIDRs = [ + '192.168.1.1/32', + '192.168.1.0/24', + '192.168.0.0/16', + '0.0.0.0/0', + ' 192.168.1.0/24 ', + '192.168.1.0 / 24' + ]; + + validCIDRs.forEach(cidr => { + const result = validateIPAddress(cidr); + expect(result.isValid).toBe(true); + expect(result.error).toBe(null); + }); + }); + }); + + describe('validateIPAddress - Invalid: Missing CIDR notation', () => { + it('should reject missing or invalid CIDR notation', () => { + const testCases = [ + { input: '192.168.1.1', expectedError: 'ipManager.validation.cidrRequired' }, + { input: '', expectedError: 'ipManager.validation.ipRequired' }, + { input: null, expectedError: 'ipManager.validation.ipRequired' }, + { input: undefined, expectedError: 'ipManager.validation.ipRequired' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); + + describe('validateIPAddress - Invalid: Bad IP octets', () => { + it('should reject invalid IP octets', () => { + const testCases = [ + { input: '192.168.1.256/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192.168.-1.1/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192.168.1.1.1/32', expectedError: 'ipManager.validation.invalidFormat' }, + { input: '192.168.1/24', expectedError: 'ipManager.validation.invalidFormat' }, + { input: 'abc.def.ghi.jkl/32', expectedError: 'ipManager.validation.invalidOctet' }, + { input: '192..1.1/32', expectedError: 'ipManager.validation.invalidOctet' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); + + describe('validateIPAddress - Invalid: Bad subnet masks', () => { + it('should reject invalid subnet masks', () => { + const invalidMasks = [ + '192.168.1.0/33', + '192.168.1.0/-1', + '192.168.1.0/', + '192.168.1.0/abc' + ]; + + invalidMasks.forEach(input => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe('ipManager.validation.invalidMask'); + }); + }); + }); + + describe('validateIPAddress - Invalid: Malformed CIDR', () => { + it('should reject malformed CIDR notation', () => { + const testCases = [ + { input: '192.168.1.0/24/16', expectedError: 'ipManager.validation.cidrRequired' }, + { input: '192.168.1.0/', expectedError: 'ipManager.validation.invalidMask' } + ]; + + testCases.forEach(({ input, expectedError }) => { + const result = validateIPAddress(input); + expect(result.isValid).toBe(false); + expect(result.error).toBe(expectedError); + }); + }); + }); +}); + From 05f21649e556c6026ccf7e3d3bc50827e630d438 Mon Sep 17 00:00:00 2001 From: Suren Date: Fri, 7 Nov 2025 16:11:06 +0530 Subject: [PATCH 16/94] Enhance Itinerary panel (#11657) --- .../epics/__tests__/isochrone-test.js | 54 ++++++++++ .../plugins/Isochrone/epics/isochrone.js | 13 +++ .../components/GraphHopperProvider.jsx | 10 +- .../epics/__tests__/itinerary-test.js | 99 +++++++++++++++++-- .../plugins/Itinerary/epics/itinerary.js | 21 +++- 5 files changed, 186 insertions(+), 11 deletions(-) diff --git a/web/client/plugins/Isochrone/epics/__tests__/isochrone-test.js b/web/client/plugins/Isochrone/epics/__tests__/isochrone-test.js index 7466790d4e8..cf0950a35ce 100644 --- a/web/client/plugins/Isochrone/epics/__tests__/isochrone-test.js +++ b/web/client/plugins/Isochrone/epics/__tests__/isochrone-test.js @@ -18,6 +18,7 @@ import { isochroneUpdateLocationMapEpic, onIsochroneRunEpic, onCloseIsochroneEpic, + onToggleControlIsochroneEpic, isochroneAddAsLayerEpic } from '../isochrone'; import { @@ -585,4 +586,57 @@ describe('Isochrone Epics', () => { ); }); }); + + describe('onToggleControlIsochroneEpic', () => { + it('should disable isochrone when a different control is toggled and isochrone is enabled', (done) => { + const NUMBER_OF_ACTIONS = 1; + + testEpic( + addTimeoutEpic(onToggleControlIsochroneEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl('otherControl'), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe('SET_CONTROL_PROPERTY'); + expect(actions[0].control).toBe(CONTROL_NAME); + expect(actions[0].property).toBe('enabled'); + expect(actions[0].value).toBe(false); + done(); + }, + mockStore.getState() + ); + }); + + it('should not trigger when the isochrone control itself is toggled', (done) => { + const NUMBER_OF_ACTIONS = 1; + + testEpic( + addTimeoutEpic(onToggleControlIsochroneEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl(CONTROL_NAME), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe(TEST_TIMEOUT); + done(); + }, + mockStore.getState() + ); + }); + + it('should not trigger when isochrone is disabled', (done) => { + const NUMBER_OF_ACTIONS = 1; + + testEpic( + addTimeoutEpic(onToggleControlIsochroneEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl('otherControl'), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe(TEST_TIMEOUT); + done(); + }, + mockStoreDisabled.getState() + ); + }); + }); }); diff --git a/web/client/plugins/Isochrone/epics/isochrone.js b/web/client/plugins/Isochrone/epics/isochrone.js index 9a1f94b9025..63eeac73f08 100644 --- a/web/client/plugins/Isochrone/epics/isochrone.js +++ b/web/client/plugins/Isochrone/epics/isochrone.js @@ -235,6 +235,19 @@ export const onIsochroneRunEpic = (action$) => return Observable.of(...actions); }); +/** + * Handles toggling of isochrone control + * @memberof epics.isochrone + * @param {external:Observable} action$ manages `TOGGLE_CONTROL` + * @return {external:Observable} + */ +export const onToggleControlIsochroneEpic = (action$, {getState}) => + action$.ofType(TOGGLE_CONTROL) + .filter(({control}) => control !== CONTROL_NAME && enabledSelector(getState())) + .switchMap(() => { + return Observable.of(setControlProperty(CONTROL_NAME, 'enabled', false)); + }); + /** * Handles closing of isochrone * @memberof epics.isochrone diff --git a/web/client/plugins/Itinerary/components/GraphHopperProvider.jsx b/web/client/plugins/Itinerary/components/GraphHopperProvider.jsx index 88813b45b85..d2d4265041d 100644 --- a/web/client/plugins/Itinerary/components/GraphHopperProvider.jsx +++ b/web/client/plugins/Itinerary/components/GraphHopperProvider.jsx @@ -55,6 +55,13 @@ const GraphHopperProvider = ({ }, []); const [avoidRoads, setAvoidRoads] = useState([]); + // Reset avoidRoads + useEffect(() => { + if (providerConfig.custom_model === undefined) { + setAvoidRoads(prev => prev.length > 0 ? [] : prev); + } + }, [providerConfig.custom_model]); + const handleProviderBodyChange = (key, value) => { let _value = value; setProviderConfig(prev => { @@ -173,8 +180,9 @@ const GraphHopperProvider = ({ key={option.value} checked={avoidRoads.includes(option.value)} onChange={(e) => { + const isChecked = e.target?.checked; setAvoidRoads(prev => { - const newAvoidRoads = e.target.checked + const newAvoidRoads = isChecked ? [...prev, option.value] : prev.filter(item => item !== option.value); handleProviderBodyChange('custom_model', newAvoidRoads); diff --git a/web/client/plugins/Itinerary/epics/__tests__/itinerary-test.js b/web/client/plugins/Itinerary/epics/__tests__/itinerary-test.js index c96033027ab..305b8fa063a 100644 --- a/web/client/plugins/Itinerary/epics/__tests__/itinerary-test.js +++ b/web/client/plugins/Itinerary/epics/__tests__/itinerary-test.js @@ -9,7 +9,7 @@ import expect from 'expect'; import MockAdapter from 'axios-mock-adapter'; import axios from '../../../../libs/ajax'; -import { testEpic } from '../../../../epics/__tests__/epicTestUtils'; +import { testEpic, addTimeoutEpic, TEST_TIMEOUT } from '../../../../epics/__tests__/epicTestUtils'; import { itinerarySearchByLocationNameEpic, itineraryMapLayoutEpic, @@ -17,6 +17,7 @@ import { itinerarySelectLocationFromMapEpic, onItineraryRunEpic, onCloseItineraryEpic, + onToggleControlItineraryEpic, itineraryAddRouteAsLayerEpic, itineraryUpdateLocationEpic, onItineraryErrorEpic @@ -38,7 +39,8 @@ import { } from '../../../../actions/maplayout'; import { TOGGLE_CONTROL, - SET_CONTROL_PROPERTY + SET_CONTROL_PROPERTY, + toggleControl } from '../../../../actions/controls'; import { CONTROL_NAME, ITINERARY_ROUTE_LAYER } from '../../constants'; import { ADD_LAYER } from '../../../../actions/layers'; @@ -414,7 +416,15 @@ describe('Itinerary Epics', () => { value: false }; - testEpic(onCloseItineraryEpic, 4, action, (actions) => { + const state = { + controls: { + [CONTROL_NAME]: { + enabled: true + } + } + }; + + testEpic(onCloseItineraryEpic, 5, action, (actions) => { expect(actions[0].type).toBe(SET_ITINERARY_DATA); expect(actions[0].data).toBeFalsy(); expect(actions[1].type).toBe(REMOVE_ADDITIONAL_LAYER); @@ -422,9 +432,12 @@ describe('Itinerary Epics', () => { expect(actions[1].owner).toBe(CONTROL_NAME); expect(actions[2].type).toBe(REMOVE_ALL_ADDITIONAL_LAYERS); expect(actions[2].owner).toBe(CONTROL_NAME + '_waypoint_marker'); - expect(actions[3].type).toBe(UPDATE_LOCATIONS); - expect(actions[3].locations).toEqual([]); - }, {}, done); + expect(actions[3].type).toBe(CHANGE_MAPINFO_STATE); + expect(actions[3].enabled).toBe(true); + expect(actions[4].type).toBe(UPDATE_LOCATIONS); + expect(actions[4].locations).toEqual([]); + done(); + }, state, done); }); it('should close itinerary when RESET_ITINERARY is dispatched', (done) => { @@ -687,4 +700,78 @@ describe('Itinerary Epics', () => { expect(actions[0].level).toBe('error'); }, {}, done); }); + + describe('onToggleControlItineraryEpic', () => { + it('should disable itinerary when a different control is toggled and itinerary is enabled', (done) => { + const NUMBER_OF_ACTIONS = 1; + const state = { + controls: { + [CONTROL_NAME]: { + enabled: true + } + } + }; + + testEpic( + addTimeoutEpic(onToggleControlItineraryEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl('otherControl'), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe(SET_CONTROL_PROPERTY); + expect(actions[0].control).toBe(CONTROL_NAME); + expect(actions[0].property).toBe('enabled'); + expect(actions[0].value).toBe(false); + done(); + }, + state + ); + }); + + it('should not trigger when the itinerary control itself is toggled', (done) => { + const NUMBER_OF_ACTIONS = 1; + const state = { + controls: { + [CONTROL_NAME]: { + enabled: true + } + } + }; + + testEpic( + addTimeoutEpic(onToggleControlItineraryEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl(CONTROL_NAME), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe(TEST_TIMEOUT); + done(); + }, + state + ); + }); + + it('should not trigger when itinerary is disabled', (done) => { + const NUMBER_OF_ACTIONS = 1; + const state = { + controls: { + [CONTROL_NAME]: { + enabled: false + } + } + }; + + testEpic( + addTimeoutEpic(onToggleControlItineraryEpic, 10), + NUMBER_OF_ACTIONS, + toggleControl('otherControl'), + actions => { + expect(actions.length).toBe(NUMBER_OF_ACTIONS); + expect(actions[0].type).toBe(TEST_TIMEOUT); + done(); + }, + state + ); + }); + }); }); diff --git a/web/client/plugins/Itinerary/epics/itinerary.js b/web/client/plugins/Itinerary/epics/itinerary.js index 6f25ed7341c..a71e1984958 100644 --- a/web/client/plugins/Itinerary/epics/itinerary.js +++ b/web/client/plugins/Itinerary/epics/itinerary.js @@ -221,16 +221,29 @@ export const onItineraryRunEpic = (action$) => ).startWith(setItinerary(null)); }); +/** + * Handles toggling of itinerary control + * @memberof epics.itinerary + * @param {external:Observable} action$ manages `TOGGLE_CONTROL` + * @return {external:Observable} + */ +export const onToggleControlItineraryEpic = (action$, {getState}) => + action$.ofType(TOGGLE_CONTROL) + .filter(({control}) => control !== CONTROL_NAME && enabledSelector(getState())) + .switchMap(() => { + return Observable.of(setControlProperty(CONTROL_NAME, 'enabled', false)); + }); + /** * Handles itinerary close * @memberof epics.itinerary * @param {external:Observable} action$ manages `SET_CONTROL_PROPERTY` | `RESET_ITINERARY` | `UPDATE_LOCATIONS` | `SET_ITINERARY_ERROR` * @return {external:Observable} */ -export const onCloseItineraryEpic = (action$) => - action$.ofType(SET_CONTROL_PROPERTY, RESET_ITINERARY, UPDATE_LOCATIONS, SET_ITINERARY_ERROR) +export const onCloseItineraryEpic = (action$, {getState}) => + action$.ofType(SET_CONTROL_PROPERTY, RESET_ITINERARY, UPDATE_LOCATIONS, SET_ITINERARY_ERROR, TOGGLE_CONTROL) .filter(({control, value, type}) => - control === CONTROL_NAME && !value || + (control === CONTROL_NAME && (!value || !enabledSelector(getState()))) || [RESET_ITINERARY, UPDATE_LOCATIONS, SET_ITINERARY_ERROR].includes(type)) .switchMap(({type, locations = []}) => { let $actions = [ @@ -240,7 +253,7 @@ export const onCloseItineraryEpic = (action$) => ].concat( // Add markers for locations based on updated locations to keep map and itinerary data consistent locations.filter(Boolean).map((location, index) => addMarkerFeature(location, index)) - ); + ).concat([SET_CONTROL_PROPERTY, TOGGLE_CONTROL].includes(type) ? [changeMapInfoState(true)] : []); // Retain location when locations are updated or on itinerary run error if (![UPDATE_LOCATIONS, SET_ITINERARY_ERROR].includes(type)) { From 0a8ea9be3f54e11d3c71dd704ae693c7e94c1c86 Mon Sep 17 00:00:00 2001 From: Tobia Di Pisa Date: Fri, 7 Nov 2025 17:11:57 +0100 Subject: [PATCH 17/94] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 33b94b250ae..002a0760191 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ MapStore's architecture is designed for modularity and extensibility, allowing d For more information check the MapStore documentation! +Also check out the MapStore project entry pageav ailable online at https://mapstore.io/ + ## Documentation You can find more documentation about how to build, install or develop with MapStore on the documentation site. From ce48cf48e1360fd6593e09642cafd583b9209d54 Mon Sep 17 00:00:00 2001 From: Tobia Di Pisa Date: Fri, 7 Nov 2025 17:12:37 +0100 Subject: [PATCH 18/94] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 002a0760191..79b09114e7b 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MapStore's architecture is designed for modularity and extensibility, allowing d For more information check the MapStore documentation! -Also check out the MapStore project entry pageav ailable online at https://mapstore.io/ +Also check out the MapStore project entry page available online at https://mapstore.io/ ## Documentation From e17baea99b0d88d5bd40c7c115280b700d8df1d3 Mon Sep 17 00:00:00 2001 From: Lorenzo Natali Date: Mon, 10 Nov 2025 09:41:59 +0100 Subject: [PATCH 19/94] Fix link formatting in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 79b09114e7b..be50e3a4d11 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MapStore's architecture is designed for modularity and extensibility, allowing d For more information check the MapStore documentation! -Also check out the MapStore project entry page available online at https://mapstore.io/ +Also check out the MapStore project entry page available online at [mapstore.io(https://mapstore.io/) ## Documentation From 8c5297c253799dba594389e7baebcf8f32c4f52b Mon Sep 17 00:00:00 2001 From: Lorenzo Natali Date: Mon, 10 Nov 2025 09:42:27 +0100 Subject: [PATCH 20/94] Fix Markdown link syntax in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index be50e3a4d11..6404ece293c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ MapStore's architecture is designed for modularity and extensibility, allowing d For more information check the MapStore documentation! -Also check out the MapStore project entry page available online at [mapstore.io(https://mapstore.io/) +Also check out the MapStore project entry page available online at [mapstore.io](https://mapstore.io/) ## Documentation From c63877182605d9857f197c88885113aa892d5c79 Mon Sep 17 00:00:00 2001 From: RowHeat <40065760+rowheat02@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:43:42 +0545 Subject: [PATCH 21/94] Map Templates list is not retained when replacing the current map #11660 (#11661) --- .../reducers/__tests__/maptemplates-test.js | 36 +++++++++++++++++++ web/client/reducers/maptemplates.js | 17 ++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 web/client/reducers/__tests__/maptemplates-test.js diff --git a/web/client/reducers/__tests__/maptemplates-test.js b/web/client/reducers/__tests__/maptemplates-test.js new file mode 100644 index 00000000000..1633e88d311 --- /dev/null +++ b/web/client/reducers/__tests__/maptemplates-test.js @@ -0,0 +1,36 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import maptemplates from '../maptemplates'; +import { configureMap } from '../../actions/config'; + +describe('mapTemplates reducer', () => { + it('Do not replace templates if "configureMap" action has no mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1', name: 'Template 1' }] + }; + const state = maptemplates(initialState, configureMap({ })); + expect(state.templates).toEqual(initialState.templates); + }); + it('Do not replace templates if "configureMap" action has empty array of mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1', name: 'Template 1' }] + }; + const state = maptemplates(initialState, configureMap({ })); + expect(state.templates).toEqual(initialState.templates); + }); + it('Replace templates when "configureMap" action has provided some mapTemplates as config', () => { + const initialState = { + templates: [{ id: 'template-1' }] + }; + const toUpdateTemplates = [{id: 'updated-template'}]; + const state = maptemplates(initialState, configureMap({ mapTemplates: toUpdateTemplates })); + expect(state.templates).toBe(toUpdateTemplates); + }); +}); diff --git a/web/client/reducers/maptemplates.js b/web/client/reducers/maptemplates.js index 37261207f31..3b0fc2049cd 100644 --- a/web/client/reducers/maptemplates.js +++ b/web/client/reducers/maptemplates.js @@ -18,7 +18,22 @@ export default (state = {}, action) => { return {}; } case MAP_CONFIG_LOADED: { - return set('templates', action.config?.mapTemplates, state) ?? []; + // NOTE: This is a dynamic reducer plugin that only functions when the map template plugin is active. + // It does not run during the initial LOAD_MAP_CONFIG phase. + // + // This reducer is triggered in two specific scenarios: + // CASE 1: When the session is cleared to restore the original templates. + // CASE 2: When map templates are replaced — in this case, mapTemplates is not provided, + // so the current list should be preserved instead of being overwritten. + // Case 3: When adding templates, comes well prepared map templates(overridden - original + from user session ) + // Clarification: + // The initial setup of map templates is handled by the `setSessionToDynamicReducers` + // function in the userSession epic. The MAP_CONFIG_LOADED action does *not* initialize map templates on load. + const mapTemplates = action.config?.mapTemplates; + if (mapTemplates === undefined || (Array.isArray(mapTemplates) && mapTemplates.length === 0)) { + return state; + } + return set('templates', mapTemplates, state); } case SET_TEMPLATES: { return set('templates', action.templates, state); From 19c6358d8d8c44253db979663e9d552b63ecf015 Mon Sep 17 00:00:00 2001 From: mahmoud adel <58145645+mahmoudadel54@users.noreply.github.com> Date: Tue, 11 Nov 2025 10:52:02 +0200 Subject: [PATCH 22/94] #11655: Fix Auth token not sent in /rest requests for rule manager (#11666) --- .../mapstore-migration-guide.md | 22 +++++++++++++++++++ web/client/configs/localConfig.json | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/developer-guide/mapstore-migration-guide.md b/docs/developer-guide/mapstore-migration-guide.md index 5b32f042783..c66eacb0b28 100644 --- a/docs/developer-guide/mapstore-migration-guide.md +++ b/docs/developer-guide/mapstore-migration-guide.md @@ -22,6 +22,28 @@ This is a list of things to check if you want to update from a previous version ## Migration from 2025.01.01 to 2025.02.00 +### Update authenticationRules in localConfig.json + +The previous default authentication rule used a broad pattern (`.*geostore.*`) that unintentionally matched internal GeoServer delegation endpoints (e.g., `/rest/security/usergroup/service/geostore/...`). This could cause delegated user/group requests to fail due to forced `bearer` authentication overriding the intended method (e.g., `authkey`). + +To avoid this conflict, update the authenticationRules entry in localConfig.json as follows: + +``` diff +{ + "authenticationRules": [ + { +- "urlPattern": ".*geostore.*", ++ "urlPattern": ".*rest/geostore.*", + "method": "bearer" + }, + { + "urlPattern": ".*rest/config.*", + "method": "bearer" + } + ] +} +``` + ### Set minimum NodeJS version to 20 Node 16 and 18 are at end of life. Therefore there is no reason to keep maintaining compatibility with these old versions. In the meantime we want to concentrate to Make MapStore compatible with future version of NodeJS, and update the libraries to reduce the dependency tree. diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 905f2f20dfa..547fb5bdd69 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -50,7 +50,7 @@ }, "authenticationRules": [ { - "urlPattern": ".*geostore.*", + "urlPattern": ".*rest/geostore.*", "method": "bearer" }, { From f57a44c59a108eb81b5b2a29d85afcd00b6666ed Mon Sep 17 00:00:00 2001 From: stefano bovio Date: Wed, 12 Nov 2025 09:09:29 +0100 Subject: [PATCH 23/94] Fix #11479 Add validation and support for editing restrictions in attribute table (#11483) * Fix #11479 Add validation and support for editing restrictions in attribute table * fix eslint errors * code refactor * enhance validation and add unit tests * update unit test --------- Co-authored-by: Suren --- package.json | 1 + .../components/I18N/IntlNumberFormControl.jsx | 4 +- .../data/featuregrid/FeatureGrid.jsx | 13 +- .../featuregrid/editors/EnumerateEditor.jsx | 74 +++ .../data/featuregrid/editors/NumberEditor.jsx | 49 +- .../__tests__/EnumerateEditor-test.jsx | 341 +++++++++++++ .../editors/__tests__/NumberEditor-test.jsx | 3 +- .../data/featuregrid/editors/index.jsx | 50 +- .../data/featuregrid/enhancers/editor.js | 20 +- .../featuregrid/renderers/CellRenderer.jsx | 36 +- .../renderers/CellValidationErrorMessage.jsx | 63 +++ .../CellValidationErrorMessage-test.jsx | 226 ++++++++ .../data/featuregrid/toolbars/Toolbar.jsx | 28 +- web/client/epics/wfsquery.js | 6 +- .../plugins/featuregrid/FeatureEditor.jsx | 31 +- .../__tests__/FeatureEditor-test.jsx | 4 +- .../__tests__/useFeatureValidation-test.js | 483 ++++++++++++++++++ .../featuregrid/hooks/useFeatureValidation.js | 93 ++++ .../plugins/featuregrid/panels/index.jsx | 4 +- web/client/selectors/query.js | 1 + .../themes/default/less/react-data-grid.less | 87 +++- web/client/translations/data.ca-ES.json | 14 +- web/client/translations/data.de-DE.json | 14 +- web/client/translations/data.en-US.json | 14 +- web/client/translations/data.es-ES.json | 14 +- web/client/translations/data.fr-FR.json | 14 +- web/client/translations/data.it-IT.json | 14 +- web/client/utils/FeatureGridUtils.js | 65 ++- web/client/utils/FeatureTypeUtils.js | 111 +++- .../utils/__tests__/FeatureGridUtils-test.js | 185 ++++++- 30 files changed, 1952 insertions(+), 110 deletions(-) create mode 100644 web/client/components/data/featuregrid/editors/EnumerateEditor.jsx create mode 100644 web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx create mode 100644 web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx create mode 100644 web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx create mode 100644 web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js create mode 100644 web/client/plugins/featuregrid/hooks/useFeatureValidation.js diff --git a/package.json b/package.json index 0228eaf4254..5ad434495a1 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "@turf/point-on-surface": "4.1.0", "@turf/polygon-to-linestring": "4.1.0", "@znemz/cesium-navigation": "4.0.0", + "ajv": "8.17.1", "assert": "2.0.0", "axios": "0.30.2", "@babel/standalone": "7.23.9", diff --git a/web/client/components/I18N/IntlNumberFormControl.jsx b/web/client/components/I18N/IntlNumberFormControl.jsx index 8dfea3fd98c..729ff162d00 100644 --- a/web/client/components/I18N/IntlNumberFormControl.jsx +++ b/web/client/components/I18N/IntlNumberFormControl.jsx @@ -137,7 +137,7 @@ class IntlNumberFormControl extends React.Component { parse = value => { let formatValue = value; // eslint-disable-next-line use-isnan - if (formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse + if (formatValue !== '' && formatValue !== NaN && formatValue !== "NaN") { // Allow locale string to parse const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const format = new Intl.NumberFormat(locale); const parts = format.formatToParts(12345.6); @@ -164,7 +164,7 @@ class IntlNumberFormControl extends React.Component { }; format = val => { - if (!isNaN(val) && val !== "NaN") { + if (val !== '' && !isNaN(val) && val !== "NaN") { const locale = this.context && this.context.intl && this.context.intl.locale || "en-US"; const formatter = new Intl.NumberFormat(locale, {minimumFractionDigits: 0, maximumFractionDigits: 20}); return formatter.format(val); diff --git a/web/client/components/data/featuregrid/FeatureGrid.jsx b/web/client/components/data/featuregrid/FeatureGrid.jsx index 19168b7e20a..083ae38ece3 100644 --- a/web/client/components/data/featuregrid/FeatureGrid.jsx +++ b/web/client/components/data/featuregrid/FeatureGrid.jsx @@ -80,7 +80,18 @@ class FeatureGrid extends React.PureComponent { this.props.changes[id].hasOwnProperty(key); }, isProperty: (k) => k === "geometry" || isProperty(k, this.props.describeFeatureType), - isValid: (val, key) => this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : true + isValid: (val, key, rowId) => { + const { errors = [], changed } = (this.props?.validationErrors?.[rowId] || {}); + // Extract field name from instancePath or dataPath (e.g., "/fid" -> "fid") + const error = errors.find((err) => { + const path = err.instancePath || err.dataPath || ''; + return path.replace(/^[./]/, '') === key; + }); + if (error) { + return { valid: false, message: error?.message, changed }; + } + return { valid: this.props.describeFeatureType ? isValidValueForPropertyName(val, key, this.props.describeFeatureType) : false }; + } }; } render() { diff --git a/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx new file mode 100644 index 00000000000..eafed1640f8 --- /dev/null +++ b/web/client/components/data/featuregrid/editors/EnumerateEditor.jsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { Combobox } from 'react-widgets'; +import AttributeEditor from './AttributeEditor'; +import { isNil } from 'lodash'; + +const EnumerateEditorItem = (props) => { + const { value, label } = props.item || {}; + return value === null ? : label; +}; +/** + * Editor of the FeatureGrid, that allows to enumerate options for current property + * @memberof components.data.featuregrid.editors + * @name EnumerateEditor + * @class + */ +export default class EnumerateEditor extends AttributeEditor { + static propTypes = { + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.null + ]), + schema: PropTypes.object, + column: PropTypes.object, + onTemporaryChanges: PropTypes.func + }; + + static defaultProps = { + column: {} + }; + + constructor(props) { + super(props); + this.state = { selected: this.getOption(props.value) }; + } + + getOption = (value) => { + return { value, label: isNil(value) ? '' : `${value}` }; + } + + getValue = () => { + return { + [this.props.column.key]: this.state?.selected?.value + }; + } + + render() { + const options = (this.props?.schema?.enum || []); + const isValid = options.includes(this.state?.selected?.value); + return ( +
+ { + this.setState({ selected: selected ? selected : this.getOption(null) }); + }} + /> +
+ ); + } +} diff --git a/web/client/components/data/featuregrid/editors/NumberEditor.jsx b/web/client/components/data/featuregrid/editors/NumberEditor.jsx index 4e9c506faef..8a98625b19f 100644 --- a/web/client/components/data/featuregrid/editors/NumberEditor.jsx +++ b/web/client/components/data/featuregrid/editors/NumberEditor.jsx @@ -8,7 +8,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {isNumber} from 'lodash'; +import { isNumber, castArray } from 'lodash'; import IntlNumberFormControl from '../../../I18N/IntlNumberFormControl'; import { editors } from 'react-data-grid'; @@ -29,7 +29,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { static propTypes = { value: PropTypes.oneOfType([ PropTypes.string, - PropTypes.number]), + PropTypes.number, + PropTypes.null + ]), inputProps: PropTypes.object, dataType: PropTypes.string, minValue: PropTypes.number, @@ -45,12 +47,14 @@ export default class NumberEditor extends editors.SimpleTextEditor { constructor(props) { super(props); - - this.state = {inputText: props.value?.toString?.() ?? ''}; + const value = props.value?.toString?.() ?? ''; + this.state = { + inputText: value, + isValid: this.validateTextValue(value), + validated: true + }; } - state = {inputText: ''}; - componentDidMount() { this.props.onTemporaryChanges?.(true); } @@ -62,9 +66,9 @@ export default class NumberEditor extends editors.SimpleTextEditor { getValue() { try { - const numberValue = parsers[this.props.dataType](this.state.inputText); + const numberValue = this.state.inputText === '' ? null : parsers[this.props.dataType](this.state.inputText); return { - [this.props.column.key]: this.validateNumberValue(numberValue) ? numberValue : this.props.value + [this.props.column.key]: numberValue }; } catch (e) { return { @@ -73,16 +77,21 @@ export default class NumberEditor extends editors.SimpleTextEditor { } } + getMinValue() { + return this.props?.column?.schema?.minimum ?? this.props.minValue; + } + + getMaxValue() { + return this.props?.column?.schema?.maximum ?? this.props.maxValue; + } + render() { - return (); + />
); } validateTextValue = (value) => { + if (value === '') { + return castArray(this.props?.column?.schema?.type || []).includes('null'); + } if (!parsers[this.props.dataType]) { return false; } - try { const numberValue = parsers[this.props.dataType](value); @@ -112,9 +123,11 @@ export default class NumberEditor extends editors.SimpleTextEditor { }; validateNumberValue = (value) => { + const minValue = this.getMinValue(); + const maxValue = this.getMaxValue(); return isNumber(value) && !isNaN(value) && - (!isNumber(this.props.minValue) || this.props.minValue <= value) && - (!isNumber(this.props.maxValue) || this.props.maxValue >= value); + (!isNumber(minValue) || minValue <= value) && + (!isNumber(maxValue) || maxValue >= value); }; } diff --git a/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx new file mode 100644 index 00000000000..d4198094736 --- /dev/null +++ b/web/client/components/data/featuregrid/editors/__tests__/EnumerateEditor-test.jsx @@ -0,0 +1,341 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import expect from 'expect'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +import EnumerateEditor from '../EnumerateEditor'; + +let testColumn = { + key: 'columnKey' +}; + +describe('FeatureGrid EnumerateEditor component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should render with valid value from enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('option1'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should render with invalid value not in enum', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('invalidOption'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle null value', () => { + const schema = { + 'enum': ['option1', 'option2', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is in enum, so should be valid + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle null value when not in enum', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(null); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // null is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle number values in enum', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); + + it('should handle empty enum array', () => { + const schema = { + 'enum': [] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('anyValue'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // Empty enum means no valid options, so any value is invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle missing schema', () => { + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe('value'); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // No enum means no valid options + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should handle undefined value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(undefined); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + // undefined is not in enum, so should be invalid + expect(editorDiv.className).toInclude('invalid'); + }); + + it('should call getOption correctly for null value', () => { + const schema = { + 'enum': ['option1', null] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(null); + expect(option.value).toBe(null); + expect(option.label).toBe(''); + }); + + it('should call getOption correctly for string value', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption('test'); + expect(option.value).toBe('test'); + expect(option.label).toBe('test'); + }); + + it('should call getOption correctly for number value', () => { + const schema = { + 'enum': [1, 2, 3] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const option = cmp.getOption(42); + expect(option.value).toBe(42); + expect(option.label).toBe('42'); + }); + + it('should handle onTemporaryChanges callback', (done) => { + const onTemporaryChanges = () => done(); + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + it('should handle column without key', () => { + const schema = { + 'enum': ['option1', 'option2'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + const result = cmp.getValue(); + expect(result).toBeTruthy(); + expect(result.undefined).toBe('option1'); + }); + + it('should initialize state with value prop', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + }); + + it('should initialize state with different value when remounted', () => { + const schema = { + 'enum': ['option1', 'option2', 'option3'] + }; + let cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option1'); + + // Unmount to create a fresh instance + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + + // Remount with different value + cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp.state.selected.value).toBe('option2'); + }); + + it('should handle mixed enum types (string and number)', () => { + const schema = { + 'enum': ['option1', 2, 'option3'] + }; + const cmp = ReactDOM.render( + , + document.getElementById("container") + ); + expect(cmp).toBeTruthy(); + expect(cmp.getValue().columnKey).toBe(2); + const container = document.getElementById("container"); + const editorDiv = container.querySelector('.ms-cell-editor'); + expect(editorDiv).toBeTruthy(); + expect(editorDiv.className).toNotInclude('invalid'); + }); +}); + diff --git a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx index 685659f18d1..01d72359d6b 100644 --- a/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx +++ b/web/client/components/data/featuregrid/editors/__tests__/NumberEditor-test.jsx @@ -61,8 +61,9 @@ describe('FeatureGrid NumberEditor/IntegerEditor component', () => { expect(inputElement.value).toBe('1.1'); TestUtils.Simulate.change(inputElement, {target: {value: '1.6'}}); - expect(cmp.getValue().columnKey).toBe(1.1); + expect(cmp.getValue().columnKey).toBe(1.6); expect(cmp.state.isValid).toBe(false); + expect(cmp.state.validated).toBe(true); }); it('Number Editor passed validation', () => { const cmp = ReactDOM.render( { + return !!props?.schema?.enum?.length; +}; + +// Create number editor (int or number type) +const createNumberEditor = (dataType) => (props) => { + return shouldUseEnumeratorComponent(props) + ? + : ; +}; + +// Create string editor +const createStringEditor = (props) => { + if (shouldUseEnumeratorComponent(props)) { + return ; + } + if (props.autocompleteEnabled) { + return ; + } + return ; +}; const types = { - "defaultEditor": (props) => , - "int": (props) => , - "number": (props) => , - "string": (props) => props.autocompleteEnabled ? - : - , - "boolean": (props) => , + "defaultEditor": (props) => , + "int": createNumberEditor("int"), + "number": createNumberEditor("number"), + "string": createStringEditor, + "boolean": (props) => ( + + ), "date-time": (props) => , - "date": (props) => , + "date": (props) => , "time": (props) => }; + export default (type, props) => types[type] ? types[type](props) : types.defaultEditor(props); diff --git a/web/client/components/data/featuregrid/enhancers/editor.js b/web/client/components/data/featuregrid/enhancers/editor.js index c5a3635344f..734553772e2 100644 --- a/web/client/components/data/featuregrid/enhancers/editor.js +++ b/web/client/components/data/featuregrid/enhancers/editor.js @@ -128,15 +128,18 @@ const featuresToGrid = compose( withPropsOnChange( ["features", "newFeatures", "isFocused", "virtualScroll", "pagination"], props => { - const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length || (props.pagination && props.pagination.totalFeatures) || 0; + const rowsCount = (props.isFocused || !props.virtualScroll) && props.rows && props.rows.length + || (props.pagination && props.pagination.totalFeatures) + || 0; + const newFeaturesLength = props?.newFeatures?.length || 0; return { - rowsCount + rowsCount: rowsCount + newFeaturesLength }; } ), withHandlers({rowGetter: props => props.virtualScroll && (i => getRowVirtual(i, props.rows, props.pages, props.size)) || (i => getRow(i, props.rows))}), withPropsOnChange( - ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable"], + ["describeFeatureType", "fields", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable", "featurePropertiesJSONSchema", "primaryKeyAttributes"], props => { const getFilterRendererFunc = ({name}) => { if (props.filterRenderers && props.filterRenderers[name]) { @@ -145,22 +148,23 @@ const featuresToGrid = compose( // return empty component if no filter renderer is defined, to avoid failures return () => null; }; - const result = ({ columns: getToolColumns(props.tools, props.rowGetter, props.describeFeatureType, props.actionOpts, getFilterRendererFunc) - .concat(featureTypeToGridColumns(props.describeFeatureType, props.columnSettings, props.fields, { + .concat(featureTypeToGridColumns(props.describeFeatureType, props.featurePropertiesJSONSchema, props.columnSettings, props.fields, { editable: props.mode === "EDIT", sortable: props.sortable && !props.isFocused, defaultSize: props.defaultSize, - options: props.options?.propertyName + options: props.options?.propertyName, + primaryKeyAttributes: props.primaryKeyAttributes || [] }, { getHeaderRenderer, - getEditor: (desc) => { + getEditor: (desc, filed, schema) => { const generalProps = { onTemporaryChanges: props.gridEvents && props.gridEvents.onTemporaryChanges, autocompleteEnabled: props.autocompleteEnabled, url: props.url, - typeName: props.typeName + typeName: props.typeName, + schema }; const regexProps = {attribute: desc.name, url: props.url, typeName: props.typeName}; const rules = props.customEditorsOptions && props.customEditorsOptions.rules || []; diff --git a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx index d8157f745c4..e3834d06b1e 100644 --- a/web/client/components/data/featuregrid/renderers/CellRenderer.jsx +++ b/web/client/components/data/featuregrid/renderers/CellRenderer.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Cell } from 'react-data-grid'; +import CellValidationErrorMessage from './CellValidationErrorMessage'; class CellRenderer extends React.Component { static propTypes = { @@ -11,7 +12,8 @@ class CellRenderer extends React.Component { static contextTypes = { isModified: PropTypes.func, isProperty: PropTypes.func, - isValid: PropTypes.func + isValid: PropTypes.func, + cellControls: PropTypes.any }; static defaultProps = { value: null, @@ -23,12 +25,36 @@ class CellRenderer extends React.Component { this.setScrollLeft = (scrollBy) => this.refs.cell.setScrollLeft(scrollBy); } render() { + const value = this.props.rowData.get(this.props.column.key); const isProperty = this.context.isProperty(this.props.column.key); const isModified = (this.props.rowData._new && isProperty) || this.context.isModified(this.props.rowData.id, this.props.column.key); - const isValid = isProperty ? this.context.isValid(this.props.rowData.get(this.props.column.key), this.props.column.key) : true; - const className = (isModified ? ['modified'] : []) - .concat(isValid ? [] : ['invalid']).join(" "); - return ; + const { valid, message, changed } = isProperty + ? this.context.isValid(value, this.props.column.key, this.props.rowData.id) + : { valid: true }; + const isPrimaryKey = this.props.column?.isPrimaryKey; + const className = [ + ...(isModified ? ['modified'] : []), + ...(valid ? [] : ['invalid']), + ...(isPrimaryKey ? ['primary-key'] : []) + ].join(" "); + return ( + + {this.props.cellControls} + + } + /> + ); } } diff --git a/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx new file mode 100644 index 00000000000..87fe7a4ad59 --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/CellValidationErrorMessage.jsx @@ -0,0 +1,63 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import withTooltip from '../../../misc/enhancers/tooltip'; +import { Glyphicon } from 'react-bootstrap'; +import { isNil } from 'lodash'; +import { getRestrictionsMessageInfo } from '../../../../utils/FeatureGridUtils'; +import Message from '../../../I18N/Message'; + +const GlyphiconIndicator = withTooltip(Glyphicon); + +const CellValidationErrorMessage = ({ + value, + valid, + column, + changed +}) => { + + if (valid || column.key === 'geometry') { + return null; + } + const restrictionsMessageInfo = getRestrictionsMessageInfo(column?.schema, column?.schemaRequired); + const isPrimaryKey = column?.isPrimaryKey; + return ( + <> + {/* when the value is empty we need a placeholder to fill the height of the field */} + {value === '' || isNil(value) ? : null} + + :
+ {(restrictionsMessageInfo?.msgIds || []).map(msgId => +
)} +
+ } + glyph="exclamation-mark" + /> + + ); +}; + +CellValidationErrorMessage.propTypes = { + value: PropTypes.any, + valid: PropTypes.bool, + changed: PropTypes.bool, + column: PropTypes.object +}; + +CellValidationErrorMessage.defaultProps = { + value: null, + column: {} +}; + +export default CellValidationErrorMessage; diff --git a/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx new file mode 100644 index 00000000000..d3e5412a1f2 --- /dev/null +++ b/web/client/components/data/featuregrid/renderers/__tests__/CellValidationErrorMessage-test.jsx @@ -0,0 +1,226 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; + +import CellValidationErrorMessage from '../CellValidationErrorMessage'; + +describe('Tests CellValidationErrorMessage component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should return null when valid is true', () => { + const props = { + value: 'test', + valid: true, + column: { key: 'testColumn' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should return null when column.key is geometry', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'geometry' }, + changed: false + }; + const comp = ReactDOM.render(, document.getElementById("container")); + expect(comp).toBe(null); + const container = document.getElementById("container"); + expect(container.querySelector('.ms-cell-validation-indicator')).toNotExist(); + }); + + it('should render validation error indicator when valid is false', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + }); + + it('should show placeholder span when value is empty string', () => { + const props = { + value: '', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + expect(placeholder.style.height).toBe('1em'); + expect(placeholder.style.display).toBe('inline-block'); + }); + + it('should show placeholder span when value is null', () => { + const props = { + value: null, + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const placeholder = container.querySelector('span[style*="height: 1em"]'); + expect(placeholder).toBeTruthy(); + }); + + it('should show danger class when changed is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show warning class when changed is false and not primary key', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: false }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-warning-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should show info class when isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-info-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-warning-text'); + }); + + it('should show danger class when changed is true even if isPrimaryKey is true', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn', isPrimaryKey: true }, + changed: true + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('ms-danger-text'); + expect(indicator.getAttribute('class')).toNotInclude('ms-info-text'); + }); + + it('should render with default props', () => { + ReactDOM.render(, document.getElementById("container")); + // When valid is undefined (falsy) and column.key is undefined (not 'geometry'), it should render + // But since column is {} by default, column.key is undefined, so it will render + const container = document.getElementById("container"); + // Component should render because valid is falsy and column.key is not 'geometry' + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle missing column prop gracefully', () => { + const props = { + value: 'test', + valid: false, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + // Should render because valid is false and column.key is undefined (not 'geometry') + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should render glyphicon with exclamation-mark', () => { + const props = { + value: 'test', + valid: false, + column: { key: 'testColumn' }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('class')).toInclude('glyphicon'); + expect(indicator.getAttribute('class')).toInclude('glyphicon-exclamation-mark'); + }); + + it('should handle column with schema and schemaRequired', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn', + schema: { type: 'string', minLength: 5 }, + schemaRequired: true + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); + + it('should handle column without schema', () => { + const props = { + value: 'test', + valid: false, + column: { + key: 'testColumn' + }, + changed: false + }; + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById("container"); + const indicator = container.querySelector('.ms-cell-validation-indicator'); + expect(indicator).toBeTruthy(); + }); +}); + diff --git a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx index 47a5b15e197..a9571203f14 100644 --- a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx +++ b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx @@ -21,10 +21,13 @@ const getDrawFeatureTooltip = (isDrawing, isSimpleGeom) => { } return isSimpleGeom ? "featuregrid.toolbar.drawGeom" : "featuregrid.toolbar.addGeom"; }; -const getSaveMessageId = ({saving, saved}) => { +const getSaveMessageId = ({ saving, saved, error }) => { if (saving || saved) { return "featuregrid.toolbar.saving"; } + if (error) { + return "featuregrid.toolbar.validationError"; + } return "featuregrid.toolbar.saveChanges"; }; const standardButtons = { @@ -86,15 +89,20 @@ const standardButtons = { visible={mode === "EDIT" && selectedCount > 0 && !hasChanges && !hasNewFeatures} onClick={events.deleteFeatures} glyph="trash-square"/>), - saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}}) => (), + saveFeature: ({saving = false, saved = false, disabled, mode, hasChanges, hasNewFeatures, events = {}, validationErrors = {} }) => { + const hasValidationErrors = Object.keys(validationErrors).some(key => validationErrors[key].changed); + return (); + }, cancelEditing: ({disabled, mode, hasChanges, hasNewFeatures, events = {}}) => ( { return conf; }), original: data, + attributesJSONSchema: describeFeatureTypeToJSONSchema(data), attributes: describeFeatureTypeToAttributes(data, fields) }; }; diff --git a/web/client/plugins/featuregrid/FeatureEditor.jsx b/web/client/plugins/featuregrid/FeatureEditor.jsx index 6479667a0ad..835d7940491 100644 --- a/web/client/plugins/featuregrid/FeatureEditor.jsx +++ b/web/client/plugins/featuregrid/FeatureEditor.jsx @@ -5,7 +5,7 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -import React, {useMemo} from 'react'; +import React, { useMemo } from 'react'; import {connect} from 'react-redux'; import {createSelector, createStructuredSelector} from 'reselect'; import {bindActionCreators} from 'redux'; @@ -19,16 +19,16 @@ import BorderLayout from '../../components/layout/BorderLayout'; import { toChangesMap} from '../../utils/FeatureGridUtils'; import { sizeChange, setUp, setSyncTool } from '../../actions/featuregrid'; import {mapLayoutValuesSelector} from '../../selectors/maplayout'; -import {paginationInfo, describeSelector, wfsURLSelector, typeNameSelector, isSyncWmsActive} from '../../selectors/query'; +import {paginationInfo, describeSelector, attributesJSONSchemaSelector, wfsURLSelector, typeNameSelector, isSyncWmsActive} from '../../selectors/query'; import {modeSelector, changesSelector, newFeaturesSelector, hasChangesSelector, selectedLayerFieldsSelector, selectedFeaturesSelector, getDockSize} from '../../selectors/featuregrid'; import {getPanels, getHeader, getFooter, getDialogs, getEmptyRowsView, getFilterRenderers} from './panels/index'; import {gridTools, gridEvents, pageEvents, toolbarEvents} from './index'; +import useFeatureValidation from './hooks/useFeatureValidation'; const EMPTY_ARR = []; const EMPTY_OBJ = {}; - const Dock = connect(createSelector( getDockSize, state => mapLayoutValuesSelector(state, {transform: true}), @@ -97,6 +97,7 @@ const Dock = connect(createSelector( * @prop {object} cfg.dateFormats object containing custom formats for one of the date/time attribute types. Following keys are supported: "date-time", "date", "time" * @prop {boolean} cfg.useUTCOffset avoid using UTC dates in attribute table and datetime editor, should be kept consistent with dateFormats, default is true * @prop {boolean} cfg.showPopoverSync default false. Hide the popup of map sync if false, shows the popup of map sync if true + * @prop {string[]} cfg.primaryKeyAttributes array of attribute names that should be considered primary keys. Default is an empty array * * @classdesc * `FeatureEditor` Plugin, also called *FeatureGrid*, provides functionalities to browse/edit data via WFS. The grid can be configured to use paging or @@ -193,6 +194,19 @@ const FeatureDock = (props = { const filterRenderers = useMemo(() => { return getFilterRenderers(props.describe, props.fields, props.isWithinAttrTbl); }, [props.describe, props.fields]); + + // changes compute using useMemo to reduce the re-render of the component + const changes = useMemo(() => toChangesMap(props.changes), [props.changes]); + + const primaryKeyAttributes = useMemo(() => props?.primaryKeyAttributes ?? [], [props?.primaryKeyAttributes]); + const validationErrors = useFeatureValidation({ + featurePropertiesJSONSchema: props.featurePropertiesJSONSchema, + features: props.features, + newFeatures: props.newFeatures, + changes, + primaryKeyAttributes + }); + return (
{ props.onSizeChange(size, dockProps); }}> @@ -208,7 +222,8 @@ const FeatureDock = (props = { toolbarItems, hideCloseButton: props.hideCloseButton, hideLayerTitle: props.hideLayerTitle, - pluginCfg: props.pluginCfg + pluginCfg: props.pluginCfg, + validationErrors })} columns={getPanels(props.tools)} footer={getFooter(props)}> @@ -226,7 +241,7 @@ const FeatureDock = (props = { emptyRowsView={getEmptyRowsView()} focusOnEdit={props.focusOnEdit} newFeatures={props.newFeatures} - changes={props.changes} + changes={changes} mode={props.mode} select={props.select} key={"feature-grid-container"} @@ -248,6 +263,9 @@ const FeatureDock = (props = { actionOpts={{maxZoom}} dateFormats={props.dateFormats} useUTCOffset={props.useUTCOffset} + validationErrors={validationErrors} + featurePropertiesJSONSchema={props.featurePropertiesJSONSchema} + primaryKeyAttributes={primaryKeyAttributes} /> } @@ -264,12 +282,13 @@ export const selector = createStructuredSelector({ typeName: state => typeNameSelector(state), features: state => get(state, 'featuregrid.features') || EMPTY_ARR, describe: describeSelector, + featurePropertiesJSONSchema: attributesJSONSchemaSelector, fields: selectedLayerFieldsSelector, attributes: state => get(state, "featuregrid.attributes"), tools: state => get(state, "featuregrid.tools"), select: selectedFeaturesSelector, mode: modeSelector, - changes: state => toChangesMap(changesSelector(state)), + changes: state => changesSelector(state), newFeatures: state => newFeaturesSelector(state) || EMPTY_ARR, hasChanges: hasChangesSelector, focusOnEdit: state => get(state, 'featuregrid.focusOnEdit', false), diff --git a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx index d8e399fdb74..d748c5ae538 100644 --- a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx +++ b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx @@ -14,7 +14,7 @@ describe('FeatureEditor plugin component', () => { canEdit: false, focusOnEdit: false, mode: "view", - changes: [], + changes: {}, pagination: { page: 0, size: 20 @@ -32,11 +32,13 @@ describe('FeatureEditor plugin component', () => { }; const BASE_EXPECTED = { open: false, + customEditorsOptions: undefined, autocompleteEnabled: undefined, url: undefined, typeName: undefined, features: [], describe: undefined, + featurePropertiesJSONSchema: undefined, fields: [], attributes: undefined, tools: undefined, diff --git a/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js b/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js new file mode 100644 index 00000000000..03061ff74eb --- /dev/null +++ b/web/client/plugins/featuregrid/hooks/__tests__/useFeatureValidation-test.js @@ -0,0 +1,483 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import expect from 'expect'; +import { act } from 'react-dom/test-utils'; +import useFeatureValidation from '../useFeatureValidation'; + +describe('useFeatureValidation', () => { + let validationResults = null; + + const Component = (props) => { + validationResults = useFeatureValidation(props); + return
{JSON.stringify(validationResults)}
; + }; + + beforeEach((done) => { + document.body.innerHTML = '
'; + validationResults = null; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + validationResults = null; + setTimeout(done); + }); + + it('should return empty object when schema is not provided', () => { + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return empty object when schema is null', () => { + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return empty object when all features are valid', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }; + const features = [ + { id: '1', properties: { name: 'John', age: 30 } }, + { id: '2', properties: { name: 'Jane', age: 25 } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + expect(validationResults).toEqual({}); + }); + + it('should return validation errors for invalid features', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + age: { type: 'number', minimum: 0 } + }, + required: ['name'] + }; + const features = [ + { id: '1', properties: { name: 'Jo', age: 30 } }, // name too short + { id: '2', properties: { name: 'Jane', age: -5 } }, // age negative + { id: '3', properties: { age: 25 } } // missing required name + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(3); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].errors).toBeTruthy(); + expect(validationResults['1'].errors.length).toBeGreaterThan(0); + expect(validationResults['1'].changed).toBeFalsy(); + expect(validationResults['2']).toBeTruthy(); + expect(validationResults['2'].errors).toBeTruthy(); + expect(validationResults['2'].errors.length).toBeGreaterThan(0); + expect(validationResults['2'].changed).toBeFalsy(); + expect(validationResults['3']).toBeTruthy(); + expect(validationResults['3'].errors).toBeTruthy(); + expect(validationResults['3'].errors.length).toBeGreaterThan(0); + expect(validationResults['3'].changed).toBeFalsy(); + }); + + it('should filter out primary key errors', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string' }, + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { fid: 'invalid', name: 'Jo' } } // fid is primary key, name is invalid + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + expect(validationResults['1']).toBeTruthy(); + // Should only have errors for 'name', not 'fid' + const errorFields = validationResults['1'].errors.map(e => { + const path = e.instancePath || e.dataPath || ''; + return path.replace(/^[./]/, ''); + }); + expect(errorFields).toNotContain('fid'); + expect(errorFields).toContain('name'); + }); + + it('should mark changed features correctly', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'John' } } // Valid initially + ]; + const changes = { + '1': { name: 'Jo' } // Invalid change - name too short + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].errors).toBeTruthy(); + expect(validationResults['1'].errors.length).toBeGreaterThan(0); + expect(validationResults['1'].changed).toBe(true); + }); + + it('should mark new features correctly', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const newFeatures = [ + { id: 'new1', properties: { name: 'Jo' }, _new: true } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults.new1).toBeTruthy(); + expect(validationResults.new1.changed).toBe(true); + }); + + it('should combine newFeatures and features', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'Jo' } } + ]; + const newFeatures = [ + { id: 'new1', properties: { name: 'Ja' }, _new: true } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(2); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults.new1).toBeTruthy(); + }); + + it('should handle features without id', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + const features = [ + { properties: { name: 'John' } }, // no id + { id: '1', properties: { name: 'Jane' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should only include feature with id + expect(Object.keys(validationResults).length).toBe(0); + }); + + it('should handle empty features array', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + }); + + it('should handle empty newFeatures array', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' } + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + }); + + it('should exclude features with only primary key errors', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string', minLength: 5 }, + name: { type: 'string' } + } + }; + const features = [ + { id: '1', properties: { fid: 'ab', name: 'John' } } // only fid has error, which is primary key + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should not include this feature since only primary key has errors + expect(validationResults).toEqual({}); + }); + + it('should handle multiple primary key attributes', () => { + const schema = { + type: 'object', + properties: { + fid: { type: 'string' }, + ogc_fid: { type: 'string' }, + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { fid: 'invalid', ogc_fid: 'invalid', name: 'Jo' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + const errorFields = validationResults['1'].errors.map(e => { + const path = e.instancePath || e.dataPath || ''; + return path.replace(/^[./]/, ''); + }); + expect(errorFields).toNotContain('fid'); + expect(errorFields).toNotContain('ogc_fid'); + expect(errorFields).toContain('name'); + }); + + it('should handle features with null properties', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' } + } + }; + const features = [ + { id: '1', properties: null } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should handle gracefully, likely will have validation errors for required fields or type mismatches + expect(validationResults).toBeTruthy(); + }); + + it('should update validation when changes are applied', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 } + } + }; + const features = [ + { id: '1', properties: { name: 'John' } } + ]; + let changes = {}; + + const TestComponent = (props) => { + validationResults = useFeatureValidation({ + featurePropertiesJSONSchema: schema, + features: features, + changes: props.changes + }); + return
{JSON.stringify(validationResults)}
; + }; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(validationResults).toEqual({}); + + // Apply invalid change + changes = { '1': { name: 'Jo' } }; + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + expect(Object.keys(validationResults).length).toBe(1); + expect(validationResults['1']).toBeTruthy(); + expect(validationResults['1'].changed).toBe(true); + }); + + it('should handle schema with no properties', () => { + const schema = { + type: 'object' + }; + const features = [ + { id: '1', properties: { name: 'John' } } + ]; + + act(() => { + ReactDOM.render( + , + document.getElementById("container") + ); + }); + + // Should handle gracefully + expect(validationResults).toBeTruthy(); + }); +}); + diff --git a/web/client/plugins/featuregrid/hooks/useFeatureValidation.js b/web/client/plugins/featuregrid/hooks/useFeatureValidation.js new file mode 100644 index 00000000000..8c0efa0254d --- /dev/null +++ b/web/client/plugins/featuregrid/hooks/useFeatureValidation.js @@ -0,0 +1,93 @@ +/* + * Copyright 2025, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useMemo } from 'react'; +import Ajv from 'ajv'; +import { applyAllChanges, isPrimaryKeyField } from '../../../utils/FeatureGridUtils'; + +const ajv = new Ajv({ allErrors: true }); + +// Cache compiled validators to avoid recompiling on every render +const validatorCache = new Map(); + +/** + * Get or compile a validator for the given schema + * @param {object} schema - The JSON schema to validate against + * @returns {function} The compiled AJV validator function + */ +const getValidator = (schema) => { + if (!schema) return null; + + const key = JSON.stringify(schema); + if (validatorCache.has(key)) return validatorCache.get(key); + try { + const validate = ajv.compile(schema); + validatorCache.set(key, validate); + return validate; + } catch (error) { + console.error('Error compiling JSON schema validator:', error); + return null; + } +}; + +const useFeatureValidation = ({ + featurePropertiesJSONSchema, + newFeatures, + features, + changes, + primaryKeyAttributes = [] +}) => { + const validate = useMemo(() => getValidator(featurePropertiesJSONSchema), [featurePropertiesJSONSchema]); + + const allFeatures = useMemo(() => { + return newFeatures && newFeatures.length > 0 + ? [...newFeatures, ...features] + : features; + }, [newFeatures, features]); + + const validationErrors = useMemo(() => { + if (!validate || !featurePropertiesJSONSchema) { + return {}; + } + + // Create default null properties + const defaultNullProperties = featurePropertiesJSONSchema?.properties + ? Object.fromEntries(Object.keys(featurePropertiesJSONSchema.properties).map(key => [key, null])) + : {}; + + return Object.fromEntries( + allFeatures + .map((feature) => { + const { id, properties } = applyAllChanges(feature, changes) || {}; + if (!id) return null; + + const valid = validate({ ...defaultNullProperties, ...properties }); + if (!valid) { + // Filter out primary key errors + const errors = (validate.errors || []).filter(error => { + // Extract field name from instancePath (e.g., "/fid" -> "fid") + const path = error.instancePath || error.dataPath || ''; + const fieldName = path.replace(/^[./]/, ''); + return !isPrimaryKeyField(fieldName, primaryKeyAttributes); + }); + + // Only include this feature if there are non-primary-key errors + if (errors.length > 0) { + return [id, { errors, changed: !!changes[id] || feature._new }]; + } + } + return null; + }) + .filter(value => value) + ); + }, [validate, allFeatures, changes, featurePropertiesJSONSchema, primaryKeyAttributes]); + + return validationErrors; +}; + +export default useFeatureValidation; diff --git a/web/client/plugins/featuregrid/panels/index.jsx b/web/client/plugins/featuregrid/panels/index.jsx index d07fc5af1b5..beb296a2020 100644 --- a/web/client/plugins/featuregrid/panels/index.jsx +++ b/web/client/plugins/featuregrid/panels/index.jsx @@ -175,8 +175,8 @@ export const getPanels = (tools = {}) => const Panel = panels[t]; return ; }); -export const getHeader = ({ hideCloseButton, hideLayerTitle, toolbarItems, pluginCfg }) => { - return
; +export const getHeader = ({ hideCloseButton, hideLayerTitle, toolbarItems, pluginCfg, validationErrors }) => { + return
; }; export const getFooter = (props) => { return ( props.focusOnEdit && props.hasChanges || props.newFeatures.length > 0) ? null :