From 80639e91690c0cde282ee4ad0a1f8c8063a902b9 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Fri, 20 Feb 2026 06:30:48 +0100 Subject: [PATCH] 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 --- .../App/Backend/DataTypes/Methods/Session.cs | 12 +- .../dashboards/Tree/CustomTreeItem.tsx | 26 ++- .../src/content/dashboards/Tree/MoveModal.tsx | 165 ++++++++++++++++++ .../contexts/InstallationsContextProvider.tsx | 38 ++++ 4 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 typescript/frontend-marios2/src/content/dashboards/Tree/MoveModal.tsx diff --git a/csharp/App/Backend/DataTypes/Methods/Session.cs b/csharp/App/Backend/DataTypes/Methods/Session.cs index 110b9ab1b..b9aee8351 100644 --- a/csharp/App/Backend/DataTypes/Methods/Session.cs +++ b/csharp/App/Backend/DataTypes/Methods/Session.cs @@ -14,9 +14,9 @@ public static class SessionMethods var user = session?.User; return user is not null - && folder is not null - && user.UserType!=0 - && user.HasAccessTo(folder.Parent()) + && folder is not null + && user.UserType==2 + && user.HasAccessTo(folder.Parent()) && Db.Create(folder) // TODO: these two in a transaction && Db.Create(new FolderAccess { UserId = user.Id, FolderId = folder.Id }); } @@ -43,8 +43,8 @@ public static class SessionMethods var parent = Db.GetFolderById(parentId); return user is not null - && folder is not null - && user.UserType !=0 + && folder is not null + && user.UserType==2 && user.HasAccessTo(folder) && user.HasAccessTo(parent) && folder @@ -61,7 +61,7 @@ public static class SessionMethods if(installation == null || installation.ParentId == parentId) return false; return user is not null - && user.UserType !=0 + && user.UserType==2 && user.HasAccessTo(installation) && user.HasAccessTo(parent) && installation diff --git a/typescript/frontend-marios2/src/content/dashboards/Tree/CustomTreeItem.tsx b/typescript/frontend-marios2/src/content/dashboards/Tree/CustomTreeItem.tsx index 036540aa7..469d9658d 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Tree/CustomTreeItem.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Tree/CustomTreeItem.tsx @@ -1,15 +1,19 @@ 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 { I_Folder, I_Installation } from 'src/interfaces/InstallationTypes'; import FolderIcon from '@mui/icons-material/Folder'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import DriveFileMoveOutlinedIcon from '@mui/icons-material/DriveFileMoveOutlined'; import Typography from '@mui/material/Typography'; import { makeStyles } from '@mui/styles'; import CancelIcon from '@mui/icons-material/Cancel'; import routes from 'src/Resources/routes.json'; import { useLocation, useNavigate } from 'react-router-dom'; import { ProductIdContext } from '../../../contexts/ProductIdContextProvider'; +import { UserContext } from '../../../contexts/userContext'; +import { UserType } from '../../../interfaces/UserTypes'; +import MoveModal from './MoveModal'; interface CustomTreeItemProps { node: I_Installation | I_Folder; @@ -41,8 +45,10 @@ function CustomTreeItem(props: CustomTreeItemProps) { const status = props.node.status; const navigate = useNavigate(); const [selected, setSelected] = useState(false); + const [openMoveModal, setOpenMoveModal] = useState(false); const currentLocation = useLocation(); const { product } = useContext(ProductIdContext); + const { currentUser } = useContext(UserContext); const handleSelectOneInstallation = (): void => { let installation = props.node; @@ -126,6 +132,24 @@ function CustomTreeItem(props: CustomTreeItemProps) { {props.node.name} + {currentUser.userType === UserType.admin && ( +
e.stopPropagation()}> + setOpenMoveModal(true)} + sx={{ ml: 1 }} + > + + + {openMoveModal && ( + setOpenMoveModal(false)} + /> + )} +
+ )} + {props.node.type === 'Installation' && (
{status === -1 ? ( diff --git a/typescript/frontend-marios2/src/content/dashboards/Tree/MoveModal.tsx b/typescript/frontend-marios2/src/content/dashboards/Tree/MoveModal.tsx new file mode 100644 index 000000000..b822fbb34 --- /dev/null +++ b/typescript/frontend-marios2/src/content/dashboards/Tree/MoveModal.tsx @@ -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 { + const result = new Set([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(''); + + // 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(); + + 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 ( + {}} + aria-labelledby="move-modal" + > + + + :{' '} + + {props.node.name} + + + + + + + + + + +
+ + + + + {loading && ( + + )} + + {error && ( + + + setError(false)} + sx={{ marginLeft: '4px' }} + > + + + + )} +
+
+
+ ); +} + +export default MoveModal; diff --git a/typescript/frontend-marios2/src/contexts/InstallationsContextProvider.tsx b/typescript/frontend-marios2/src/contexts/InstallationsContextProvider.tsx index 909b68e5f..939ad344f 100644 --- a/typescript/frontend-marios2/src/contexts/InstallationsContextProvider.tsx +++ b/typescript/frontend-marios2/src/contexts/InstallationsContextProvider.tsx @@ -349,6 +349,42 @@ const InstallationsContextProvider = ({ [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( () => ({ salimax_or_sodistore_Installations, @@ -369,6 +405,8 @@ const InstallationsContextProvider = ({ createFolder, updateFolder, deleteFolder, + moveInstallation, + moveFolder, //currentProduct, socket, openSocket,