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>
This commit is contained in:
parent
062fd5141f
commit
80639e9169
|
|
@ -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,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)
|
&& user.HasAccessTo(folder)
|
||||||
&& user.HasAccessTo(parent)
|
&& user.HasAccessTo(parent)
|
||||||
&& folder
|
&& folder
|
||||||
|
|
@ -61,7 +61,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
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue