Files
ld-sysinfo-frontend/src/components/SidebarLayout.tsx
Bailey Taylor 1d882fbfee
All checks were successful
Deploy Frontend / deploy (push) Successful in 22s
Adding reporting, and some fake/dummy routes locally for testing functionality.
2025-10-29 08:20:06 +08:00

343 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 AssessmentIcon from '@mui/icons-material/Assessment';
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 /> },
{ label: 'Reporting', path: '/reporting', icon: <AssessmentIcon /> },
];
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>
);
}