214 lines
6.3 KiB
TypeScript
214 lines
6.3 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
|
import {
|
|
KeyRound,
|
|
LogOut,
|
|
Menu,
|
|
Moon,
|
|
Router,
|
|
ScrollText,
|
|
Sun,
|
|
UserCog,
|
|
Users as UsersIcon,
|
|
Wifi,
|
|
X,
|
|
} from 'lucide-react'
|
|
import { useAuth } from '@/auth/auth'
|
|
import { useTheme } from '@/lib/theme'
|
|
import { Login } from '@/pages/Login'
|
|
import { Clients } from '@/pages/Clients'
|
|
import { Devices } from '@/pages/Devices'
|
|
import { Vlans } from '@/pages/Vlans'
|
|
import { Users } from '@/pages/Users'
|
|
import { ApiKeys } from '@/pages/ApiKeys'
|
|
import { Logs } from '@/pages/Logs'
|
|
import { ChangePassword } from '@/pages/ChangePassword'
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function ThemeToggle() {
|
|
const { theme, toggle } = useTheme()
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={toggle}
|
|
className="text-muted-foreground"
|
|
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
aria-label="Toggle theme"
|
|
>
|
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
type NavItem = { to: string; label: string; icon: typeof Wifi }
|
|
|
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
cn(
|
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
|
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
|
|
)
|
|
|
|
function SidebarContent({
|
|
username,
|
|
isAdmin,
|
|
onNavigate,
|
|
}: {
|
|
username: string
|
|
isAdmin: boolean
|
|
onNavigate?: () => void
|
|
}) {
|
|
const { logout } = useAuth()
|
|
|
|
const items: NavItem[] = [
|
|
{ to: '/clients', label: 'Clients', icon: UsersIcon },
|
|
{ to: '/devices', label: 'Devices', icon: Router },
|
|
{ to: '/vlans', label: 'VLANs', icon: Wifi },
|
|
]
|
|
const adminItems: NavItem[] = [
|
|
{ to: '/users', label: 'Users', icon: UserCog },
|
|
{ to: '/apikeys', label: 'API Keys', icon: KeyRound },
|
|
{ to: '/logs', label: 'Activity Log', icon: ScrollText },
|
|
]
|
|
|
|
const renderItem = ({ to, label, icon: Icon }: NavItem) => (
|
|
<NavLink key={to} to={to} className={linkClass} onClick={onNavigate}>
|
|
<Icon className="h-4 w-4 shrink-0" /> {label}
|
|
</NavLink>
|
|
)
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="flex h-14 items-center gap-2 px-4 font-semibold">
|
|
<Wifi className="h-5 w-5 text-primary" />
|
|
RADIUS Admin
|
|
</div>
|
|
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-2">
|
|
{items.map(renderItem)}
|
|
{isAdmin && (
|
|
<>
|
|
<div className="mt-4 px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Admin
|
|
</div>
|
|
{adminItems.map(renderItem)}
|
|
</>
|
|
)}
|
|
</nav>
|
|
<div className="border-t p-3">
|
|
<NavLink to="/account" className={linkClass} onClick={onNavigate} title="Change password">
|
|
<UserCog className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{username}</span>
|
|
</NavLink>
|
|
<Button
|
|
variant="ghost"
|
|
className="mt-1 w-full justify-start gap-3 px-3 text-muted-foreground"
|
|
onClick={logout}
|
|
>
|
|
<LogOut className="h-4 w-4 shrink-0" /> Sign out
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Shell({
|
|
username,
|
|
isAdmin,
|
|
children,
|
|
}: {
|
|
username: string
|
|
isAdmin: boolean
|
|
children: React.ReactNode
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
const location = useLocation()
|
|
|
|
// Close the mobile drawer whenever the route changes.
|
|
useEffect(() => {
|
|
setOpen(false)
|
|
}, [location.pathname])
|
|
|
|
return (
|
|
<div className="min-h-screen bg-muted/20">
|
|
{/* Desktop sidebar */}
|
|
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 border-r bg-background md:block">
|
|
<SidebarContent username={username} isAdmin={isAdmin} />
|
|
</aside>
|
|
|
|
{/* Mobile drawer */}
|
|
{open && (
|
|
<div className="fixed inset-0 z-40 md:hidden">
|
|
<div
|
|
className="absolute inset-0 bg-black/50"
|
|
onClick={() => setOpen(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<aside className="absolute inset-y-0 left-0 w-64 border-r bg-background shadow-lg">
|
|
<SidebarContent
|
|
username={username}
|
|
isAdmin={isAdmin}
|
|
onNavigate={() => setOpen(false)}
|
|
/>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
{/* Mobile top bar */}
|
|
<header className="sticky top-0 z-20 flex h-14 items-center gap-2 border-b bg-background px-4 md:hidden">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setOpen((v) => !v)}
|
|
aria-label={open ? 'Close menu' : 'Open menu'}
|
|
>
|
|
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
</Button>
|
|
<div className="flex items-center gap-2 font-semibold">
|
|
<Wifi className="h-5 w-5 text-primary" />
|
|
RADIUS Admin
|
|
</div>
|
|
<div className="ml-auto">
|
|
<ThemeToggle />
|
|
</div>
|
|
</header>
|
|
|
|
<div className="md:pl-60">
|
|
{/* Desktop top-right theme toggle */}
|
|
<div className="hidden items-center justify-end px-6 pt-4 md:flex">
|
|
<ThemeToggle />
|
|
</div>
|
|
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
const { user } = useAuth()
|
|
|
|
if (!user) return <Login />
|
|
|
|
if (user.must_change_password) return <ChangePassword forced />
|
|
|
|
const admin = user.is_admin
|
|
|
|
return (
|
|
<Shell username={user.username} isAdmin={admin}>
|
|
<Routes>
|
|
<Route path="/clients" element={<Clients />} />
|
|
<Route path="/devices" element={<Devices />} />
|
|
<Route path="/vlans" element={<Vlans />} />
|
|
<Route path="/account" element={<ChangePassword />} />
|
|
<Route path="/users" element={admin ? <Users /> : <Navigate to="/clients" replace />} />
|
|
<Route
|
|
path="/apikeys"
|
|
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
|
|
/>
|
|
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
|
|
<Route path="*" element={<Navigate to="/clients" replace />} />
|
|
</Routes>
|
|
</Shell>
|
|
)
|
|
}
|