341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useTheme, styled, CSSObject, Theme } from '@mui/material/styles';
|
|
import {
|
|
AppBar as MuiAppBar,
|
|
Toolbar,
|
|
Drawer as MuiDrawer,
|
|
IconButton,
|
|
List,
|
|
ListItem,
|
|
ListItemButton,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
Box,
|
|
Typography,
|
|
Divider,
|
|
Tooltip,
|
|
Avatar,
|
|
CssBaseline,
|
|
Collapse
|
|
} from '@mui/material';
|
|
import Link from 'next/link';
|
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
|
import SecurityIcon from '@mui/icons-material/Security';
|
|
import DevicesIcon from '@mui/icons-material/Devices';
|
|
import AppsIcon from '@mui/icons-material/Apps';
|
|
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
|
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
|
import LightModeIcon from '@mui/icons-material/LightMode';
|
|
import ChangePasswordDrawer from '@/components/ChangePasswordDrawer';
|
|
import { useThemeMode } from '@/context/ThemeContext';
|
|
import { getUserInfoFromToken, getStoredToken } from '@/lib/auth';
|
|
import UserMenu from '@/components/UserMenu';
|
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
|
|
const collapsedDrawerWidth = 65;
|
|
const drawerWidth = 240;
|
|
const isDev = process.env.NEXT_PUBLIC_BUILD_ENV === 'dev';
|
|
|
|
|
|
const openedMixin = (theme: Theme): CSSObject => ({
|
|
width: drawerWidth,
|
|
transition: theme.transitions.create('width', {
|
|
easing: theme.transitions.easing.sharp,
|
|
duration: theme.transitions.duration.enteringScreen,
|
|
}),
|
|
overflowX: 'hidden',
|
|
});
|
|
|
|
const closedMixin = (theme: Theme): CSSObject => ({
|
|
transition: theme.transitions.create('width', {
|
|
easing: theme.transitions.easing.sharp,
|
|
duration: theme.transitions.duration.leavingScreen,
|
|
}),
|
|
overflowX: 'hidden',
|
|
width: `${collapsedDrawerWidth}px`,
|
|
});
|
|
|
|
|
|
const DrawerHeader = styled('div')(({ theme }) => ({
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
padding: theme.spacing(0, 1),
|
|
...theme.mixins.toolbar,
|
|
}));
|
|
|
|
const AppBar = styled(MuiAppBar, {
|
|
shouldForwardProp: (prop) => prop !== 'open',
|
|
})<{ open?: boolean }>(({ theme, open }) => ({
|
|
zIndex: theme.zIndex.drawer + 1,
|
|
marginLeft: open ? drawerWidth : collapsedDrawerWidth,
|
|
width: open
|
|
? `calc(100% - ${drawerWidth}px)`
|
|
: `calc(100% - ${collapsedDrawerWidth}px)`,
|
|
transition: theme.transitions.create(['width', 'margin'], {
|
|
easing: theme.transitions.easing.sharp,
|
|
duration: open
|
|
? theme.transitions.duration.enteringScreen
|
|
: theme.transitions.duration.leavingScreen,
|
|
}),
|
|
}));
|
|
|
|
|
|
|
|
|
|
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })(
|
|
({ theme, open }) => ({
|
|
width: drawerWidth,
|
|
flexShrink: 0,
|
|
whiteSpace: 'nowrap',
|
|
boxSizing: 'border-box',
|
|
...(open && {
|
|
...openedMixin(theme),
|
|
'& .MuiDrawer-paper': openedMixin(theme),
|
|
}),
|
|
...(!open && {
|
|
...closedMixin(theme),
|
|
'& .MuiDrawer-paper': closedMixin(theme),
|
|
}),
|
|
})
|
|
);
|
|
|
|
export default function SidebarLayout({ children }: { children: React.ReactNode }) {
|
|
const theme = useTheme();
|
|
const { darkMode, toggle } = useThemeMode();
|
|
const [open, setOpen] = useState(false);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [stayExpanded, setStayExpanded] = useState(false);
|
|
const [hoverTimer, setHoverTimer] = useState<NodeJS.Timeout | null>(null);
|
|
const [adminOpen, setAdminOpen] = useState(false);
|
|
const { username, displayname, loading, roles } = useAuth();
|
|
|
|
|
|
if (loading) return null;
|
|
|
|
//console.log('AuthContext values:', { username, displayname, roles, loading });
|
|
const mainNavItems = [
|
|
{ label: 'Dashboard', path: '/dashboard', icon: <DashboardIcon /> },
|
|
{ label: 'Vulnerabilities', path: '/vulnerabilities', icon: <SecurityIcon /> },
|
|
{ label: 'Devices', path: '/devices', icon: <DevicesIcon /> },
|
|
{ label: 'Software', path: '/software', icon: <AppsIcon /> },
|
|
];
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
|
|
localStorage.removeItem('authToken');
|
|
window.location.href = '/login?reason=session-expired';
|
|
} catch (error) {
|
|
console.error("Logout failed:", error);
|
|
window.location.href = '/login?reason=unauthorized';
|
|
}
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = localStorage.getItem('drawerPinned') === 'true';
|
|
setStayExpanded(saved);
|
|
setOpen(saved);
|
|
}
|
|
}, []);
|
|
|
|
|
|
const toggleDrawerPin = () => {
|
|
const newState = !stayExpanded;
|
|
setStayExpanded(newState);
|
|
setOpen(newState);
|
|
localStorage.setItem('drawerPinned', String(newState));
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
|
|
<CssBaseline />
|
|
|
|
{/* AppBar stays on top */}
|
|
<AppBar position="fixed" open={open} color="default" elevation={1}>
|
|
<Toolbar
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
px: 3,
|
|
minHeight: 48,
|
|
bgcolor: 'background.default',
|
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
{/* Just a spacer */}
|
|
<Box sx={{ flexGrow: 1 }} />
|
|
|
|
{/* Right-aligned build label */}
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{
|
|
fontWeight: 'bold',
|
|
color: isDev ? 'error.main' : 'success.main',
|
|
letterSpacing: 1,
|
|
}}
|
|
>
|
|
{isDev ? 'DEVELOPMENT BUILD' : 'LIVE BUILD'}
|
|
</Typography>
|
|
</Toolbar>
|
|
|
|
</AppBar>
|
|
{/* This wrapper handles sidebar + main content horizontally */}
|
|
<Box sx={{ display: 'flex', flexGrow: 1, minHeight: 0 }}>
|
|
{/* Sidebar (Drawer) */}
|
|
<Drawer
|
|
variant="permanent"
|
|
open={open}
|
|
onMouseEnter={() => {
|
|
if (!stayExpanded) {
|
|
const timer = setTimeout(() => setOpen(true), 200);
|
|
setHoverTimer(timer);
|
|
}
|
|
}}
|
|
onMouseLeave={() => {
|
|
if (!stayExpanded) {
|
|
if (hoverTimer) clearTimeout(hoverTimer);
|
|
const timer = setTimeout(() => setOpen(false), 200);
|
|
setHoverTimer(timer);
|
|
}
|
|
}}
|
|
sx={{
|
|
'& .MuiDrawer-paper': {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'space-between',
|
|
},
|
|
}}
|
|
>
|
|
<Box>
|
|
<DrawerHeader sx={{ justifyContent: open ? 'flex-start' : 'center', px: 2 }}>
|
|
<Box component={Link} href="/" sx={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}>
|
|
<Box
|
|
component="img"
|
|
src="/logo_sidebar.png"
|
|
alt="Logo"
|
|
sx={{
|
|
height: 32,
|
|
width: 32,
|
|
mx: 'auto', // center horizontally
|
|
transition: 'transform 0.2s',
|
|
transform: open ? 'scale(1)' : 'scale(0.85)',
|
|
}}
|
|
/>
|
|
{open && <Typography variant="h6" noWrap sx={{ ml: 1, fontWeight: 600, color: 'text.primary' }}>Oversight</Typography>}
|
|
</Box>
|
|
{open && (
|
|
<Tooltip title={stayExpanded ? "Unpin Drawer" : "Pin Drawer"}>
|
|
<IconButton
|
|
size="medium"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleDrawerPin();
|
|
}}
|
|
sx={{
|
|
position: 'absolute', right: -10, top: '50%', transform: 'translateY(-50%)', backgroundColor: 'background.paper',
|
|
border: '1px solid', borderColor: 'divider', borderRadius: '0 4px 4px 0', zIndex: theme.zIndex.drawer + 1,
|
|
transition: 'background-color 0.2s ease-in-out',
|
|
'&:hover': { backgroundColor: theme.palette.action.hover },
|
|
}}
|
|
>
|
|
{stayExpanded ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
)}
|
|
</DrawerHeader>
|
|
|
|
<List>
|
|
{mainNavItems.map(({ label, path, icon }) => (
|
|
<ListItem key={label} disablePadding sx={{ display: 'block' }}>
|
|
<ListItemButton component={Link} href={path} sx={{ minHeight: 48, justifyContent: open ? 'initial' : 'center', px: 2.5 }}>
|
|
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>{icon}</ListItemIcon>
|
|
<ListItemText primary={label} sx={{ opacity: open ? 1 : 0 }} />
|
|
</ListItemButton>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Divider />
|
|
<List>
|
|
{roles?.includes('ADMIN') && (
|
|
<ListItem disablePadding sx={{ display: 'block' }}>
|
|
<ListItemButton
|
|
onClick={() => setAdminOpen(!adminOpen)}
|
|
sx={{ minHeight: 48, justifyContent: open ? 'initial' : 'center', px: 2.5 }}
|
|
>
|
|
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>
|
|
<AdminPanelSettingsIcon />
|
|
</ListItemIcon>
|
|
<ListItemText primary="Admin" sx={{ opacity: open ? 1 : 0 }} />
|
|
</ListItemButton>
|
|
<Collapse in={adminOpen} timeout="auto" unmountOnExit>
|
|
<Box sx={{ pl: 4 }}>
|
|
<ListItemButton component={Link} href="/admin/users">
|
|
<ListItemText primary="User Management" />
|
|
</ListItemButton>
|
|
<ListItemButton component={Link} href="/admin/devices">
|
|
<ListItemText primary="Device Management" />
|
|
</ListItemButton>
|
|
<ListItemButton component={Link} href="/admin/settings">
|
|
<ListItemText primary="Settings" />
|
|
</ListItemButton>
|
|
<ListItemButton component={Link} href="/admin/statistics">
|
|
<ListItemText primary="Statistics" />
|
|
</ListItemButton>
|
|
</Box>
|
|
</Collapse>
|
|
</ListItem>
|
|
)}
|
|
<ListItem disablePadding sx={{ display: 'block' }}>
|
|
<ListItemButton onClick={toggle} sx={{ justifyContent: open ? 'initial' : 'center', px: 2.5 }}>
|
|
<ListItemIcon sx={{ minWidth: 0, mr: open ? 3 : 'auto', justifyContent: 'center' }}>
|
|
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
|
</ListItemIcon>
|
|
<ListItemText primary={`${darkMode ? 'Light' : 'Dark'} Mode`} sx={{ opacity: open ? 1 : 0 }} />
|
|
</ListItemButton>
|
|
</ListItem>
|
|
|
|
</List>
|
|
|
|
|
|
<Box sx={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: open ? 'space-between' : 'center', px: open ? 0 : 0, py: 0, bgcolor: 'action.hover', borderTop: `1px solid ${theme.palette.divider}`, minHeight: 50 }}>
|
|
<UserMenu username={username} onLogout={handleLogout} open={open} displayname={displayname}/>
|
|
</Box>
|
|
|
|
<ChangePasswordDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
|
</Box>
|
|
</Drawer>
|
|
|
|
{/* ⬇️ this is the actual page body */}
|
|
<Box
|
|
component="main"
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
flexGrow: 1,
|
|
minHeight: 0,
|
|
height: '100%',
|
|
paddingTop: '64px',
|
|
}}
|
|
>
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
} |