Compare commits

...

3 Commits

Author SHA1 Message Date
Yinyin Liu d464c9cd71 Fixed unawaited SendAsync calls of Websocket 2026-02-26 13:52:43 +01:00
Yinyin Liu abedc6c203 avoid adding folder to itself, children and parent 2026-02-26 13:13:38 +01:00
Yinyin Liu 80639e9169 feat: add move installation/folder UI with admin-only access control
- Add MoveModal component with folder dropdown and circular-reference protection
- Add move icon button to each tree row (admin only)
- Add moveInstallation and moveFolder functions to InstallationsContext
- Restrict CreateFolder, MoveFolder, MoveInstallation to admin only (UserType==2) in backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 13:00:10 +01:00
6 changed files with 306 additions and 47 deletions

View File

@ -15,7 +15,7 @@ public static class SessionMethods
return user is not null return user is not null
&& folder is not null && folder is not null
&& user.UserType!=0 && user.UserType==2
&& user.HasAccessTo(folder.Parent()) && user.HasAccessTo(folder.Parent())
&& Db.Create(folder) // TODO: these two in a transaction && Db.Create(folder) // TODO: these two in a transaction
&& Db.Create(new FolderAccess { UserId = user.Id, FolderId = folder.Id }); && Db.Create(new FolderAccess { UserId = user.Id, FolderId = folder.Id });
@ -44,7 +44,10 @@ public static class SessionMethods
return user is not null return user is not null
&& folder is not null && folder is not null
&& user.UserType !=0 && parent is not null
&& folderId != parentId // can't move into itself
&& !IsFolderAncestorOf(folderId, parentId) // can't move into a descendant
&& user.UserType==2
&& user.HasAccessTo(folder) && user.HasAccessTo(folder)
&& user.HasAccessTo(parent) && user.HasAccessTo(parent)
&& folder && folder
@ -52,6 +55,19 @@ public static class SessionMethods
.Apply(Db.Update); .Apply(Db.Update);
} }
// Walks up the folder tree from candidateDescendantId to check if ancestorId is an ancestor.
// Prevents circular references when moving a folder into one of its own descendants.
private static Boolean IsFolderAncestorOf(Int64 ancestorId, Int64 candidateDescendantId)
{
var current = Db.GetFolderById(candidateDescendantId);
while (current is not null && current.ParentId != 0)
{
if (current.ParentId == ancestorId) return true;
current = Db.GetFolderById(current.ParentId);
}
return false;
}
public static Boolean MoveInstallation(this Session? session, Int64 installationId, Int64 parentId) public static Boolean MoveInstallation(this Session? session, Int64 installationId, Int64 parentId)
{ {
var user = session?.User; var user = session?.User;
@ -61,7 +77,7 @@ public static class SessionMethods
if(installation == null || installation.ParentId == parentId) return false; if(installation == null || installation.ParentId == parentId) return false;
return user is not null return user is not null
&& user.UserType !=0 && user.UserType==2
&& user.HasAccessTo(installation) && user.HasAccessTo(installation)
&& user.HasAccessTo(parent) && user.HasAccessTo(parent)
&& installation && installation
@ -144,7 +160,7 @@ public static class SessionMethods
var installation = Db.GetInstallationById(action.InstallationId); var installation = Db.GetInstallationById(action.InstallationId);
installation.TestingMode = action.TestingMode; installation.TestingMode = action.TestingMode;
installation.Apply(Db.Update); installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId); await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
// Save the configuration change to the database // Save the configuration change to the database
Db.HandleAction(action); Db.HandleAction(action);
@ -163,7 +179,7 @@ public static class SessionMethods
{ {
installation.TestingMode = action.TestingMode; installation.TestingMode = action.TestingMode;
installation.Apply(Db.Update); installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId); await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
} }
Db.UpdateAction(action); Db.UpdateAction(action);
@ -183,7 +199,7 @@ public static class SessionMethods
var installation = Db.GetInstallationById(action.InstallationId); var installation = Db.GetInstallationById(action.InstallationId);
installation.TestingMode = false; installation.TestingMode = false;
installation.Apply(Db.Update); installation.Apply(Db.Update);
WebsocketManager.InformWebsocketsForInstallation(action.InstallationId); await WebsocketManager.InformWebsocketsForInstallation(action.InstallationId);
} }
Db.Delete(action); Db.Delete(action);

View File

@ -181,7 +181,7 @@ public static class RabbitMqManager
//If the status has changed, update all the connected front-ends regarding this installation //If the status has changed, update all the connected front-ends regarding this installation
if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0) if(prevStatus != receivedStatusMessage.Status && WebsocketManager.InstallationConnections[installationId].Connections.Count > 0)
{ {
WebsocketManager.InformWebsocketsForInstallation(installationId); _ = WebsocketManager.InformWebsocketsForInstallation(installationId); // fire-and-forget: sync event handler, can't await
} }
} }
} }

View File

@ -17,6 +17,8 @@ public static class WebsocketManager
{ {
while (true) while (true)
{ {
var idsToInform = new List<Int64>();
lock (InstallationConnections) lock (InstallationConnections)
{ {
Console.WriteLine("Monitoring installation table..."); Console.WriteLine("Monitoring installation table...");
@ -31,10 +33,8 @@ public static class WebsocketManager
(installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2)) (installationConnection.Value.Product == (int)ProductType.SodiStoreMax && (DateTime.Now - installationConnection.Value.Timestamp) > TimeSpan.FromMinutes(2))
) )
{ {
Console.WriteLine("Installation ID is " + installationConnection.Key); Console.WriteLine("Installation ID is " + installationConnection.Key);
Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp); Console.WriteLine("installationConnection.Value.Timestamp is " + installationConnection.Value.Timestamp);
// Console.WriteLine("diff is "+(DateTime.Now-installationConnection.Value.Timestamp));
installationConnection.Value.Status = (int)StatusType.Offline; installationConnection.Value.Status = (int)StatusType.Offline;
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key); Installation installation = Db.Installations.FirstOrDefault(f => f.Product == installationConnection.Value.Product && f.Id == installationConnection.Key);
@ -42,42 +42,59 @@ public static class WebsocketManager
installation.Apply(Db.Update); installation.Apply(Db.Update);
if (installationConnection.Value.Connections.Count > 0) if (installationConnection.Value.Connections.Count > 0)
{ {
InformWebsocketsForInstallation(installationConnection.Key); idsToInform.Add(installationConnection.Key);
} }
} }
} }
} }
// Send notifications outside the lock so we can await the async SendAsync calls
foreach (var id in idsToInform)
await InformWebsocketsForInstallation(id);
await Task.Delay(TimeSpan.FromMinutes(1)); await Task.Delay(TimeSpan.FromMinutes(1));
} }
} }
//Inform all the connected websockets regarding installation "installationId" //Inform all the connected websockets regarding installation "installationId"
public static void InformWebsocketsForInstallation(Int64 installationId) public static async Task InformWebsocketsForInstallation(Int64 installationId)
{ {
var installation = Db.GetInstallationById(installationId); var installation = Db.GetInstallationById(installationId);
var installationConnection = InstallationConnections[installationId]; byte[] dataToSend;
Console.WriteLine("Update all the connected websockets for installation " + installation.Name); List<WebSocket> connections;
var jsonObject = new lock (InstallationConnections)
{ {
id = installationId, var installationConnection = InstallationConnections[installationId];
status = installationConnection.Status, Console.WriteLine("Update all the connected websockets for installation " + installation.Name);
testingMode = installation.TestingMode
};
string jsonString = JsonSerializer.Serialize(jsonObject); var jsonObject = new
byte[] dataToSend = Encoding.UTF8.GetBytes(jsonString); {
id = installationId,
status = installationConnection.Status,
testingMode = installation.TestingMode
};
foreach (var connection in installationConnection.Connections) dataToSend = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(jsonObject));
{ connections = installationConnection.Connections.ToList(); // snapshot before releasing lock
connection.SendAsync(
new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
WebSocketMessageType.Text,
true, // Indicates that this is the end of the message
CancellationToken.None
);
} }
// Send to all connections concurrently (preserves original fire-and-forget intent),
// but isolate failures so one closed socket doesn't affect others or crash the caller.
await Task.WhenAll(connections
.Where(c => c.State == WebSocketState.Open)
.Select(async c =>
{
try
{
await c.SendAsync(new ArraySegment<byte>(dataToSend, 0, dataToSend.Length),
WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket send failed for installation {installationId}: {ex.Message}");
}
}));
} }
@ -109,7 +126,7 @@ public static class WebsocketManager
var jsonString = JsonSerializer.Serialize(jsonObject); var jsonString = JsonSerializer.Serialize(jsonObject);
var dataToSend = Encoding.UTF8.GetBytes(jsonString); var dataToSend = Encoding.UTF8.GetBytes(jsonString);
currentWebSocket.SendAsync(dataToSend, await currentWebSocket.SendAsync(dataToSend,
WebSocketMessageType.Text, WebSocketMessageType.Text,
true, true,
CancellationToken.None CancellationToken.None
@ -120,6 +137,7 @@ public static class WebsocketManager
//Received a new message from this websocket. //Received a new message from this websocket.
//We have a HandleWebSocketConnection per connected frontend //We have a HandleWebSocketConnection per connected frontend
byte[] encodedDataToSend;
lock (InstallationConnections) lock (InstallationConnections)
{ {
List<WebsocketMessage> dataToSend = new List<WebsocketMessage>(); List<WebsocketMessage> dataToSend = new List<WebsocketMessage>();
@ -157,15 +175,7 @@ public static class WebsocketManager
} }
var jsonString = JsonSerializer.Serialize(dataToSend); var jsonString = JsonSerializer.Serialize(dataToSend);
var encodedDataToSend = Encoding.UTF8.GetBytes(jsonString); encodedDataToSend = Encoding.UTF8.GetBytes(jsonString);
currentWebSocket.SendAsync(encodedDataToSend,
WebSocketMessageType.Text,
true, // Indicates that this is the end of the message
CancellationToken.None
);
// Console.WriteLine("Printing installation connection list"); // Console.WriteLine("Printing installation connection list");
// Console.WriteLine("----------------------------------------------"); // Console.WriteLine("----------------------------------------------");
@ -175,6 +185,12 @@ public static class WebsocketManager
// } // }
// Console.WriteLine("----------------------------------------------"); // Console.WriteLine("----------------------------------------------");
} }
await currentWebSocket.SendAsync(encodedDataToSend,
WebSocketMessageType.Text,
true,
CancellationToken.None
);
} }
lock (InstallationConnections) lock (InstallationConnections)

View File

@ -1,15 +1,19 @@
import React, { ReactNode, useContext, useState } from 'react'; import React, { ReactNode, useContext, useState } from 'react';
import { CircularProgress, ListItemIcon, useTheme } from '@mui/material'; import { CircularProgress, IconButton, ListItemIcon, useTheme } from '@mui/material';
import { TreeItem } from '@mui/lab'; import { TreeItem } from '@mui/lab';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes'; import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { makeStyles } from '@mui/styles'; import { makeStyles } from '@mui/styles';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import routes from 'src/Resources/routes.json'; import routes from 'src/Resources/routes.json';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider';
import { UserContext } from '../../../contexts/userContext';
import { UserType } from '../../../interfaces/UserTypes';
import MoveModal from './MoveModal';
interface CustomTreeItemProps { interface CustomTreeItemProps {
node: I_Installation | I_Folder; node: I_Installation | I_Folder;
@ -41,8 +45,10 @@ function CustomTreeItem(props: CustomTreeItemProps) {
const status = props.node.status; const status = props.node.status;
const navigate = useNavigate(); const navigate = useNavigate();
const [selected, setSelected] = useState(false); const [selected, setSelected] = useState(false);
const [openMoveModal, setOpenMoveModal] = useState(false);
const currentLocation = useLocation(); const currentLocation = useLocation();
const { product } = useContext(ProductIdContext); const { product } = useContext(ProductIdContext);
const { currentUser } = useContext(UserContext);
const handleSelectOneInstallation = (): void => { const handleSelectOneInstallation = (): void => {
let installation = props.node; let installation = props.node;
@ -126,6 +132,24 @@ function CustomTreeItem(props: CustomTreeItemProps) {
{props.node.name} {props.node.name}
</Typography> </Typography>
{currentUser.userType === UserType.admin && (
<div onClick={(e) => e.stopPropagation()}>
<IconButton
size="small"
onClick={() => setOpenMoveModal(true)}
sx={{ ml: 1 }}
>
<DriveFileMoveOutlinedIcon fontSize="small" />
</IconButton>
{openMoveModal && (
<MoveModal
node={props.node}
onClose={() => setOpenMoveModal(false)}
/>
)}
</div>
)}
{props.node.type === 'Installation' && ( {props.node.type === 'Installation' && (
<div> <div>
{status === -1 ? ( {status === -1 ? (

View File

@ -0,0 +1,165 @@
import React, { useContext, useState } from 'react';
import {
Alert,
Box,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Modal,
Select,
Typography,
useTheme
} from '@mui/material';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { ProductIdContext } from 'src/contexts/ProductIdContextProvider';
import { FormattedMessage } from 'react-intl';
interface MoveModalProps {
node: I_Installation | I_Folder;
onClose: () => void;
}
// Returns the IDs of a folder and all its descendants (to prevent circular moves)
function getDescendantIds(folderId: number, allItems: any[]): Set<number> {
const result = new Set<number>([folderId]);
const queue = [folderId];
while (queue.length > 0) {
const currentId = queue.shift();
allItems
.filter((item) => item.parentId === currentId && item.type === 'Folder')
.forEach((child) => {
result.add(child.id);
queue.push(child.id);
});
}
return result;
}
function MoveModal(props: MoveModalProps) {
const theme = useTheme();
const { foldersAndInstallations, moveInstallation, moveFolder, loading, setLoading, error, setError } =
useContext(InstallationsContext);
const { product } = useContext(ProductIdContext);
const [selectedFolderId, setSelectedFolderId] = useState<number | ''>('');
// For folders: exclude self and all descendants to prevent circular nesting
// For installations: any folder is valid
const excludedIds =
props.node.type === 'Folder'
? getDescendantIds(props.node.id, foldersAndInstallations)
: new Set<number>();
const availableFolders = foldersAndInstallations.filter(
(item) => item.type === 'Folder' && !excludedIds.has(item.id)
);
const handleSubmit = async () => {
if (selectedFolderId === '') return;
setLoading(true);
setError(false);
if (props.node.type === 'Installation') {
await moveInstallation(props.node.id, selectedFolderId as number, product);
} else {
await moveFolder(props.node.id, selectedFolderId as number, product);
}
setLoading(false);
props.onClose();
};
return (
<Modal
open={true}
onClose={() => {}}
aria-labelledby="move-modal"
>
<Box
sx={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 500,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4
}}
>
<Typography variant="subtitle1" fontWeight="bold" mb={2}>
<FormattedMessage id="moveTo" defaultMessage="Move to" />:{' '}
<span style={{ color: theme.palette.primary.main }}>
{props.node.name}
</span>
</Typography>
<FormControl fullWidth sx={{ mb: 2 }}>
<InputLabel sx={{ fontSize: 14, backgroundColor: 'white' }}>
<FormattedMessage id="folder" defaultMessage="Folder" />
</InputLabel>
<Select
value={selectedFolderId}
onChange={(e) => setSelectedFolderId(e.target.value as number)}
>
{availableFolders.map((folder: I_Folder) => (
<MenuItem key={folder.id} value={folder.id}>
{folder.name}
</MenuItem>
))}
</Select>
</FormControl>
<div style={{ display: 'flex', alignItems: 'center', marginTop: 10 }}>
<Button
variant="contained"
onClick={handleSubmit}
disabled={selectedFolderId === ''}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Button
variant="contained"
onClick={props.onClose}
sx={{ marginLeft: '10px' }}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
{loading && (
<CircularProgress
sx={{ color: theme.palette.primary.main, marginLeft: '20px' }}
/>
)}
{error && (
<Alert
severity="error"
sx={{ ml: 1, display: 'flex', alignItems: 'center' }}
>
<FormattedMessage
id="errorOccured"
defaultMessage="An error has occurred"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</Modal>
);
}
export default MoveModal;

View File

@ -349,6 +349,42 @@ const InstallationsContextProvider = ({
[fetchAllFoldersAndInstallations, navigate, removeToken] [fetchAllFoldersAndInstallations, navigate, removeToken]
); );
const moveInstallation = useCallback(
async (installationId: number, parentId: number, product: number) => {
try {
await axiosConfig.put(
`/MoveInstallation?installationId=${installationId}&parentId=${parentId}`
);
await fetchAllFoldersAndInstallations(product);
} catch (error) {
setError(true);
if (error.response?.status === 401) {
removeToken();
navigate(routes.login);
}
}
},
[fetchAllFoldersAndInstallations, navigate, removeToken]
);
const moveFolder = useCallback(
async (folderId: number, parentId: number, product: number) => {
try {
await axiosConfig.put(
`/MoveFolder?folderId=${folderId}&parentId=${parentId}`
);
await fetchAllFoldersAndInstallations(product);
} catch (error) {
setError(true);
if (error.response?.status === 401) {
removeToken();
navigate(routes.login);
}
}
},
[fetchAllFoldersAndInstallations, navigate, removeToken]
);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
salimax_or_sodistore_Installations, salimax_or_sodistore_Installations,
@ -369,6 +405,8 @@ const InstallationsContextProvider = ({
createFolder, createFolder,
updateFolder, updateFolder,
deleteFolder, deleteFolder,
moveInstallation,
moveFolder,
//currentProduct, //currentProduct,
socket, socket,
openSocket, openSocket,