diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index 67934b147..b9476bbf1 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -522,7 +522,7 @@ public class Controller : ControllerBase return Unauthorized(); return user.DirectlyAccessibleInstallations() - .Select(i => new { i.Id, i.Name }) + .Select(i => new { i.Id, i.Name, i.Product }) .ToList(); } @@ -542,6 +542,24 @@ public class Controller : ControllerBase .ToList(); } + [HttpGet(nameof(GetInstallationsUnderFolder))] + public ActionResult> GetInstallationsUnderFolder(Int64 folderId, Token authToken) + { + var sessionUser = Db.GetSession(authToken)?.User; + if (sessionUser == null) + return Unauthorized(); + + var folder = Db.GetFolderById(folderId); + if (folder == null || !sessionUser.HasAccessTo(folder)) + return Unauthorized(); + + return folder + .DescendantFoldersAndSelf() // self + all nested subfolders + .SelectMany(f => f.ChildInstallations()) // installations directly in each + .Select(i => new { i.Id, i.Name, i.Product }) + .ToList(); + } + [HttpGet(nameof(GetUsersWithInheritedAccessToInstallation))] public ActionResult> GetUsersWithInheritedAccessToInstallation(Int64 id, Token authToken) { diff --git a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx index 90801853d..9df3b5945 100644 --- a/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/ManageAccess/UserAccess.tsx @@ -10,13 +10,18 @@ import { Alert, Autocomplete, Box, + CircularProgress, + Collapse, Container, Divider, FormControl, Grid, IconButton, InputLabel, + Link, + List, ListItem, + ListItemButton, ListSubheader, MenuItem, Modal, @@ -32,6 +37,9 @@ import Avatar from '@mui/material/Avatar'; import ListItemText from '@mui/material/ListItemText'; import PersonIcon from '@mui/icons-material/Person'; import FolderIcon from '@mui/icons-material/Folder'; +import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import Button from '@mui/material/Button'; import { Close as CloseIcon } from '@mui/icons-material'; import { AccessContext } from 'src/contexts/AccessContextProvider'; @@ -43,6 +51,8 @@ import { I_Installation } from '../../../interfaces/InstallationTypes'; import axiosConfig from '../../../Resources/axiosConfig'; +import routes from 'src/Resources/routes.json'; +import { useNavigate } from 'react-router-dom'; const PRODUCT_GROUP_ORDER: number[] = [2, 5, 4, 3, 0, 1]; const PRODUCT_NAMES: Record = { @@ -91,7 +101,18 @@ function UserAccess(props: UserAccessProps) { // Direct grants for this user const [directFolders, setDirectFolders] = useState<{ id: number; name: string }[]>([]); - const [directInstallations, setDirectInstallations] = useState<{ id: number; name: string }[]>([]); + const [directInstallations, setDirectInstallations] = useState< + { id: number; name: string; product: number }[] + >([]); + + // Installations under each folder (lazy-loaded + cached), with expand/loading state + const navigate = useNavigate(); + const [expandedFolderId, setExpandedFolderId] = useState(null); + const [folderInstallations, setFolderInstallations] = useState< + Record + >({}); + const [loadingFolderId, setLoadingFolderId] = useState(null); + const [folderError, setFolderError] = useState>({}); const accessContext = useContext(AccessContext); const { @@ -169,6 +190,49 @@ function UserAccess(props: UserAccessProps) { }); }; + // Build the route to an installation from its product (mirrors CustomTreeItem). + // Targets the Tickets tab; the installation router redirects to `live` when the + // Tickets tab isn't available (non-admin), via its catch-all . + const installationLink = (inst: { id: number; product: number }): string => { + const base = + inst.product === 0 + ? routes.installations + : inst.product === 1 + ? routes.salidomo_installations + : inst.product === 2 + ? routes.sodiohome_installations + : inst.product === 4 + ? routes.sodistoregrid_installations + : inst.product === 5 + ? routes.sodistorepro_installations + : routes.sodistore_installations; + return base + routes.tree + routes.installation + inst.id + '/' + routes.installationTickets; + }; + + const fetchFolderInstallations = async (folderId: number) => { + setLoadingFolderId(folderId); + setFolderError((prev) => ({ ...prev, [folderId]: false })); + try { + const res = await axiosConfig.get(`/GetInstallationsUnderFolder?folderId=${folderId}`); + setFolderInstallations((prev) => ({ ...prev, [folderId]: res.data })); + } catch (err) { + if (err.response && err.response.status === 401) removeToken(); + else setFolderError((prev) => ({ ...prev, [folderId]: true })); + } finally { + setLoadingFolderId(null); + } + }; + + const handleToggleFolder = (folderId: number) => { + if (expandedFolderId === folderId) { + setExpandedFolderId(null); + return; + } + setExpandedFolderId(folderId); + if (folderInstallations[folderId]) return; // already cached + fetchFolderInstallations(folderId); + }; + const handleRevokeInstallation = async (installationId: number) => { axiosConfig .post(`/RevokeUserAccessToInstallation?UserId=${props.current_user.id}&InstallationId=${installationId}`) @@ -357,16 +421,27 @@ function UserAccess(props: UserAccessProps) { {directFolders.map((folder, index) => { const isLast = index === directFolders.length - 1; + const isExpanded = expandedFolderId === folder.id; + const installations = folderInstallations[folder.id]; return ( handleRevokeFolder(folder.id, folder.name)} edge="end"> - + + handleToggleFolder(folder.id)} + edge="end" + aria-label={intl.formatMessage({ id: 'viewInstallations' })} + > + {isExpanded ? : } - ) + {currentUser.userType === UserType.admin && ( + handleRevokeFolder(folder.id, folder.name)} edge="end"> + + + )} + } > @@ -376,6 +451,60 @@ function UserAccess(props: UserAccessProps) { + + {loadingFolderId === folder.id ? ( + + + + ) : folderError[folder.id] ? ( + fetchFolderInstallations(folder.id)} + > + + + } + > + + + ) : installations && installations.length > 0 ? ( + + {installations.map((inst) => ( + navigate(installationLink(inst))} + sx={{ py: 0.25 }} + > + + + + + {inst.name} + + } + /> + + ))} + + ) : installations ? ( + + + + ) : null} + ); @@ -411,7 +540,19 @@ function UserAccess(props: UserAccessProps) { - + navigate(installationLink(installation))} + > + {installation.name} + + } + /> diff --git a/typescript/frontend-marios2/src/content/dashboards/Users/FlatUsersView.tsx b/typescript/frontend-marios2/src/content/dashboards/Users/FlatUsersView.tsx index e97648e69..e927a4fd4 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Users/FlatUsersView.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Users/FlatUsersView.tsx @@ -13,6 +13,7 @@ import { useTheme } from '@mui/material'; import { FormattedMessage } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; import { InnovEnergyUser } from 'src/interfaces/UserTypes'; import User from './User'; @@ -22,14 +23,25 @@ interface FlatUsersViewProps { } const FlatUsersView = (props: FlatUsersViewProps) => { - const [selectedUser, setSelectedUser] = useState(-1); + // Selected user is kept in the URL (?userId=) so navigating away to an + // installation and pressing Back restores this user's detail pane. + const [searchParams, setSearchParams] = useSearchParams(); + const userIdParam = searchParams.get('userId'); + const [selectedUser, setSelectedUser] = useState( + userIdParam ? Number(userIdParam) : -1 + ); const handleSelectOneUser = (userID: number): void => { - if (selectedUser != userID) { - setSelectedUser(userID); + const next = selectedUser !== userID ? userID : -1; + setSelectedUser(next); + const params = new URLSearchParams(searchParams); + if (next === -1) { + params.delete('userId'); + params.delete('tab'); } else { - setSelectedUser(-1); + params.set('userId', String(next)); } + setSearchParams(params, { replace: true }); }; const theme = useTheme(); diff --git a/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx b/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx index bec15752a..78916083e 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Users/User.tsx @@ -28,6 +28,7 @@ import { TokenContext } from 'src/contexts/tokenContext'; import { UserContext } from 'src/contexts/userContext'; import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper'; import { FormattedMessage, useIntl } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; import UserAccess from '../ManageAccess/UserAccess'; interface singleUserProps { @@ -41,7 +42,11 @@ function User(props: singleUserProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(false); const [updated, setUpdated] = useState(false); - const [currentTab, setCurrentTab] = useState('user'); + // Active tab is kept in the URL (?tab=) alongside ?userId= so Back restores it. + const [searchParams, setSearchParams] = useSearchParams(); + const [currentTab, setCurrentTab] = useState( + searchParams.get('tab') === 'manage' ? 'manage' : 'user' + ); const [formValues, setFormValues] = useState(props.current_user); const tokencontext = useContext(TokenContext); const { removeToken } = tokencontext; @@ -86,6 +91,9 @@ function User(props: singleUserProps) { const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => { setCurrentTab(value); setError(false); + const params = new URLSearchParams(searchParams); + params.set('tab', value); + setSearchParams(params, { replace: true }); }; const handleChange = (e) => { diff --git a/typescript/frontend-marios2/src/lang/de.json b/typescript/frontend-marios2/src/lang/de.json index 1206fbe11..daa27eefb 100644 --- a/typescript/frontend-marios2/src/lang/de.json +++ b/typescript/frontend-marios2/src/lang/de.json @@ -157,6 +157,10 @@ "errorOccured": "Ein Fehler ist aufgetreten", "successfullyUpdated": "Erfolgreich aktualisiert", "grantAccess": "Zugriff gewähren", + "viewInstallations": "Anlagen anzeigen", + "noInstallationsInFolder": "Keine Anlagen in diesem Ordner", + "couldNotLoadInstallations": "Anlagen konnten nicht geladen werden", + "retry": "Erneut versuchen", "UserswithDirectAccess": "Benutzer mit direktem Zugriff", "UserswithInheritedAccess": "Benutzer mit vererbtem Zugriff", "noerrors": "Keine Fehler", diff --git a/typescript/frontend-marios2/src/lang/en.json b/typescript/frontend-marios2/src/lang/en.json index 88187a82f..caea7c8e9 100644 --- a/typescript/frontend-marios2/src/lang/en.json +++ b/typescript/frontend-marios2/src/lang/en.json @@ -139,6 +139,10 @@ "errorOccured": "An error has occurred", "successfullyUpdated": "Successfully updated", "grantAccess": "Grant Access", + "viewInstallations": "View installations", + "noInstallationsInFolder": "No installations in this folder", + "couldNotLoadInstallations": "Could not load installations", + "retry": "Retry", "UserswithDirectAccess": "Users with Direct Access", "UserswithInheritedAccess": "Users with Inherited Access", "noerrors": "There are no errors", diff --git a/typescript/frontend-marios2/src/lang/fr.json b/typescript/frontend-marios2/src/lang/fr.json index cbc6c2fe7..07bcf4c59 100644 --- a/typescript/frontend-marios2/src/lang/fr.json +++ b/typescript/frontend-marios2/src/lang/fr.json @@ -151,6 +151,10 @@ "errorOccured": "Une erreur s'est produite", "successfullyUpdated": "Mise à jour réussie", "grantAccess": "Accorder l'accès", + "viewInstallations": "Voir les installations", + "noInstallationsInFolder": "Aucune installation dans ce dossier", + "couldNotLoadInstallations": "Impossible de charger les installations", + "retry": "Réessayer", "UserswithDirectAccess": "Utilisateurs avec accès direct", "UserswithInheritedAccess": "Utilisateurs avec accès hérité", "noerrors": "Il n'y a pas d'erreurs", diff --git a/typescript/frontend-marios2/src/lang/it.json b/typescript/frontend-marios2/src/lang/it.json index 74e94f681..7b0adcc0c 100644 --- a/typescript/frontend-marios2/src/lang/it.json +++ b/typescript/frontend-marios2/src/lang/it.json @@ -139,6 +139,10 @@ "errorOccured": "Si è verificato un errore", "successfullyUpdated": "Aggiornamento riuscito", "grantAccess": "Concedi accesso", + "viewInstallations": "Mostra installazioni", + "noInstallationsInFolder": "Nessuna installazione in questa cartella", + "couldNotLoadInstallations": "Impossibile caricare le installazioni", + "retry": "Riprova", "UserswithDirectAccess": "Utenti con accesso diretto", "UserswithInheritedAccess": "Utenti con accesso ereditato", "noerrors": "Non ci sono errori",