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 */}
+
+
+
+
+
+ )
+}