diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 27b199b..5968fc0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { CssBaseline, ThemeProvider } from '@mui/material' -import { lightTheme } from './theme/theme' +import { darkTheme } from './theme/theme' import { useAuthStore } from './store/authStore' +import AppLayout from './components/AppLayout' import LoginPage from './pages/LoginPage' import MfaSetupPage from './pages/MfaSetupPage' import HostsPage from './pages/HostsPage' @@ -16,14 +17,6 @@ import CertificatesPage from './pages/CertificatesPage' import ReportsPage from './pages/ReportsPage' import SettingsPage from './pages/SettingsPage' -// Placeholder pages — implemented in later milestones -const PlaceholderPage = ({ title }: { title: string }) => ( -
-

{title}

-

Coming soon in a future milestone.

-
-) - function RequireAuth({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) return isAuthenticated ? <>{children} : @@ -31,38 +24,30 @@ function RequireAuth({ children }: { children: React.ReactNode }) { function App() { return ( - + {/* Public */} } /> - {/* Protected — M2 */} - } /> - } /> + {/* Protected — wrapped in AppLayout with sidebar navigation */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Protected — M3 */} - } /> - } /> - } /> - } /> - } /> - - {/* Protected — M5 */} - } /> - } /> - - {/* Protected — M6 */} - } /> - - {/* Placeholder — later milestones */} - {/* Protected — M9 */} - } /> - {/* Protected — M8 */} - } /> - } /> - - } /> + } /> ) diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..e98fa31 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,229 @@ +import { useState } from 'react' +import { Outlet, useNavigate, useLocation } from 'react-router-dom' +import { + AppBar, Box, CssBaseline, Divider, Drawer, IconButton, + List, ListItem, ListItemButton, ListItemIcon, ListItemText, + Toolbar, Typography, Avatar, Menu, MenuItem, Tooltip, +} from '@mui/material' +import { + Dashboard as DashboardIcon, + Computer as HostsIcon, + Group as GroupsIcon, + Build as DeployIcon, + Assignment as JobsIcon, + Schedule as MaintenanceIcon, + People as UsersIcon, + VerifiedUser as CertsIcon, + Assessment as ReportsIcon, + Settings as SettingsIcon, + Menu as MenuIcon, + Logout as LogoutIcon, + Person as PersonIcon, +} from '@mui/icons-material' +import { useAuthStore } from '../store/authStore' + +const DRAWER_WIDTH = 240 + +interface NavItem { + label: string + path: string + icon: React.ReactElement + adminOnly?: boolean +} + +const navGroups: { heading: string; items: NavItem[] }[] = [ + { + heading: 'Overview', + items: [ + { label: 'Dashboard', path: '/dashboard', icon: }, + ], + }, + { + heading: 'Fleet', + items: [ + { label: 'Hosts', path: '/hosts', icon: }, + { label: 'Groups', path: '/groups', icon: }, + { label: 'Deploy', path: '/deployment', icon: }, + ], + }, + { + heading: 'Operations', + items: [ + { label: 'Jobs', path: '/jobs', icon: }, + { label: 'Maintenance', path: '/maintenance', icon: }, + ], + }, + { + heading: 'Administration', + items: [ + { label: 'Users', path: '/users', icon: , adminOnly: true }, + { label: 'Certificates', path: '/certificates', icon: , adminOnly: true }, + { label: 'Reports', path: '/reports', icon: }, + { label: 'Settings', path: '/settings', icon: , adminOnly: true }, + ], + }, +] + +export default function AppLayout() { + const navigate = useNavigate() + const location = useLocation() + const { user, logout } = useAuthStore() + const [mobileOpen, setMobileOpen] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) + + const isAdmin = user?.role === 'admin' + + const handleDrawerToggle = () => setMobileOpen(!mobileOpen) + const handleMenuOpen = (e: React.MouseEvent) => setAnchorEl(e.currentTarget) + const handleMenuClose = () => setAnchorEl(null) + + const handleLogout = () => { + handleMenuClose() + logout() + navigate('/login', { replace: true }) + } + + const drawer = ( + + + + 🐉 Patch Manager + + + + + {navGroups.map((group) => { + const visibleItems = group.items.filter((item) => !item.adminOnly || isAdmin) + if (visibleItems.length === 0) return null + return ( + + + {group.heading} + + + {visibleItems.map((item) => { + const isActive = location.pathname === item.path || location.pathname.startsWith(item.path + '/') + return ( + + navigate(item.path)} + sx={{ + borderRadius: 1, + mx: 0.5, + '&.Mui-selected': { + bgcolor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { bgcolor: 'primary.dark' }, + '& .MuiListItemIcon-root': { color: 'primary.contrastText' }, + }, + }} + > + + {item.icon} + + + + + ) + })} + + + ) + })} + + + + + Linux Patch Manager v0.1.0 + + + + ) + + return ( + + + + {/* App Bar */} + theme.zIndex.drawer + 1, + borderBottom: 1, + borderColor: 'divider', + }} + > + + + + + + {navGroups.flatMap((g) => g.items).find((i) => location.pathname === i.path || location.pathname.startsWith(i.path + '/'))?.label || 'Patch Manager'} + + + + + {(user?.display_name || user?.username || '?')[0].toUpperCase()} + + + + + + + + + + + + + + + + + + {/* Sidebar */} + + {/* Mobile drawer */} + + {drawer} + + {/* Desktop drawer */} + + {drawer} + + + + {/* Main content */} + + + + + + ) +}