Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions web/client/api/GeoStoreDAO.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ const Api = {
},
writeSecurityRules: function(SecurityRuleList = {}) {
return "<SecurityRuleList>" +
(castArray(SecurityRuleList.SecurityRule) || []).map( rule => {
(castArray(SecurityRuleList.SecurityRule) || []).flatMap( rule => {
if (rule.canRead || rule.canWrite) {
if (rule.user) {
return "<SecurityRule>"
Expand All @@ -312,8 +312,18 @@ const Api = {
+ "<canWrite>" + boolToString(rule.canWrite) + "</canWrite>"
+ "<group><id>" + (rule.group.id || "") + "</id><groupName>" + (rule.group.groupName || "") + "</groupName></group>"
+ "</SecurityRule>";
} else if (rule.ipRanges) {
// Create a separate SecurityRule for each IP range
const ipRangesArray = castArray(rule.ipRanges.ipRange);
return ipRangesArray.map(ipRange =>
"<SecurityRule>"
+ "<canRead>" + boolToString(rule.canRead || rule.canWrite) + "</canRead>"
+ "<canWrite>" + boolToString(rule.canWrite) + "</canWrite>"
+ "<ipRanges><ipRange><id>" + (ipRange.id) + "</id></ipRange></ipRanges>"
+ "</SecurityRule>"
);
}
// NOTE: if rule has no group or user, it is skipped
// NOTE: if rule has no group, user, or ipRanges, it is skipped
// NOTE: if rule is "no read and no write", it is skipped
}
return "";
Expand Down Expand Up @@ -660,6 +670,42 @@ const Api = {
removeFavoriteResource: (userId, resourceId, options) => {
const url = `/users/user/${userId}/favorite/${resourceId}`;
return axios.delete(url, Api.addBaseUrl(parseOptions(options))).then((response) => response.data);
},
getIPRanges: function(options = {}) {
const url = "ipranges/";
return axios.get(url, this.addBaseUrl(parseOptions(options))).then(function(response) {return response.data || []; });
},
createIPRange: function(ipRange, options) {
const url = "ipranges/";
const xmlPayload = [
'<IPRange>',
`<cidr><![CDATA[${ipRange.cidr || ''}]]></cidr>`,
`<description><![CDATA[${ipRange.description || ''}]]></description>`,
'</IPRange>'
].join('');
return axios.post(url, xmlPayload, this.addBaseUrl(merge({
headers: {
'Content-Type': "application/xml"
}
}, parseOptions(options)))).then(function(response) {return response.data; });
},
updateIPRange: function(id, ipRange, options = {}) {
const url = "ipranges/" + id;
const xmlPayload = [
'<IPRange>',
`<cidr><![CDATA[${ipRange.cidr || ''}]]></cidr>`,
`<description><![CDATA[${ipRange.description || ''}]]></description>`,
'</IPRange>'
].join('');
return axios.put(url, xmlPayload, this.addBaseUrl(merge({
headers: {
'Content-Type': "application/xml"
}
}, parseOptions(options)))).then(function(response) {return response.data; });
},
deleteIPRange: function(id, options = {}) {
const url = "ipranges/" + id;
return axios.delete(url, this.addBaseUrl(parseOptions(options))).then(function(response) {return response.data; });
}
};

Expand Down
73 changes: 73 additions & 0 deletions web/client/components/manager/ipmanager/IPActions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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 { Button } from 'react-bootstrap';
import Message from '../../I18N/Message';
import InputControl from '../../../plugins/ResourcesCatalog/components/InputControl';

/**
* New IP button component for toolbar
*/
export function NewIP({ onNewIP }) {
return (
<Button onClick={onNewIP} bsSize="sm" bsStyle="success">
<Message msgId="ipManager.newIP" />
</Button>
);
}

/**
* Edit IP button component for card actions
*/
export function EditIP({ component, onEdit, resource: ip }) {
const Component = component;
return (
<Component
onClick={() => 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 (
<Component
onClick={() => 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 (
<InputControl
placeholder="ipManager.search"
style={{ maxWidth: 200 }}
value={query.q || ''}
debounceTime={300}
onChange={handleFieldChange}
/>
);
}

111 changes: 111 additions & 0 deletions web/client/components/manager/ipmanager/IPDialog.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal show={show} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>
<Message msgId={ip ? 'ipManager.editTitle' : 'ipManager.newIP'} />
</Modal.Title>
</Modal.Header>
<Modal.Body>
<FormGroup validationState={validationError ? 'error' : null}>
<ControlLabel><Message msgId="ipManager.ipAddress" /></ControlLabel>
<FormControl
type="text"
value={ipAddress}
onChange={e => setIpAddress(e.target.value)}
placeholder="e.g., 192.168.1.1/32 or 192.168.1.0/24"
/>
{validationError && (
<HelpBlock>
<Message msgId={validationError} />
</HelpBlock>
)}
</FormGroup>
<FormGroup>
<ControlLabel><Message msgId="ipManager.description" /></ControlLabel>
<FormControl
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="(Optional) Enter description"
/>
</FormGroup>
</Modal.Body>
<Modal.Footer>
<Button onClick={onClose} disabled={loading}>
<Message msgId="ipManager.cancel" />
</Button>
<Button bsStyle="primary" onClick={handleSave} disabled={loading}>
{loading && <Glyphicon glyph="refresh" className="spinner" />}
{' '}
<Message msgId="ipManager.save" />
</Button>
</Modal.Footer>
</Modal>
);
}

/**
* Delete confirmation dialog for IP ranges
*/
export function DeleteConfirm({ show, ip, onDelete, onClose, loading = false }) {
return (
<ConfirmDialog
show={show}
onCancel={onClose}
onConfirm={() => onDelete(ip)}
titleId="ipManager.deleteTitle"
loading={loading}
cancelId="ipManager.cancel"
confirmId="ipManager.deleteButton"
variant="danger"
>
<Text><Message msgId="ipManager.deleteConfirm" /> <b>{ip?.cidr}</b>?</Text>
</ConfirmDialog>
);
}

107 changes: 107 additions & 0 deletions web/client/components/manager/ipmanager/__tests__/IPDialog-test.jsx
Original file line number Diff line number Diff line change
@@ -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 = '<div id="container"></div>';
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(
<IPDialog show ip={null} onSave={onSave} onClose={onClose} />,
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(
<IPDialog show ip={null} onSave={onSave} onClose={onClose} />,
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 = '<div id="container"></div>';
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(
<DeleteConfirm show ip={ip} onDelete={onDelete} onClose={onClose} />,
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);
});
});

1 change: 1 addition & 0 deletions web/client/configs/localConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -1239,6 +1239,7 @@
"UserManager",
"GroupManager",
"TagsManager",
"IPManager",
"Footer",
{ "name": "About" }
]
Expand Down
7 changes: 7 additions & 0 deletions web/client/plugins/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading