This commit is contained in:
+149
-61
@@ -1,7 +1,9 @@
|
|||||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Menu,
|
||||||
Moon,
|
Moon,
|
||||||
Router,
|
Router,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@@ -9,6 +11,7 @@ import {
|
|||||||
UserCog,
|
UserCog,
|
||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
Wifi,
|
Wifi,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '@/auth/auth'
|
import { useAuth } from '@/auth/auth'
|
||||||
import { useTheme } from '@/lib/theme'
|
import { useTheme } from '@/lib/theme'
|
||||||
@@ -39,54 +42,145 @@ function ThemeToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Nav({ username, isAdmin }: { username: string; isAdmin: boolean }) {
|
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 { logout } = useAuth()
|
||||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|
||||||
cn(
|
const items: NavItem[] = [
|
||||||
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
{ to: '/clients', label: 'Clients', icon: UsersIcon },
|
||||||
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
|
{ 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 (
|
return (
|
||||||
<header className="border-b bg-background">
|
<div className="flex h-full flex-col">
|
||||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-1 px-4">
|
<div className="flex h-14 items-center gap-2 px-4 font-semibold">
|
||||||
<div className="mr-4 flex items-center gap-2 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" />
|
<Wifi className="h-5 w-5 text-primary" />
|
||||||
RADIUS Admin
|
RADIUS Admin
|
||||||
</div>
|
</div>
|
||||||
<NavLink to="/clients" className={linkClass}>
|
<div className="ml-auto">
|
||||||
<UsersIcon className="h-4 w-4" /> Clients
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/devices" className={linkClass}>
|
|
||||||
<Router className="h-4 w-4" /> Devices
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/vlans" className={linkClass}>
|
|
||||||
<Wifi className="h-4 w-4" /> VLANs
|
|
||||||
</NavLink>
|
|
||||||
{isAdmin && (
|
|
||||||
<>
|
|
||||||
<NavLink to="/users" className={linkClass}>
|
|
||||||
<UserCog className="h-4 w-4" /> Users
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/apikeys" className={linkClass}>
|
|
||||||
<KeyRound className="h-4 w-4" /> API Keys
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/logs" className={linkClass}>
|
|
||||||
<ScrollText className="h-4 w-4" /> Activity Log
|
|
||||||
</NavLink>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
<NavLink to="/account" className={linkClass} title="Change password">
|
|
||||||
{username}
|
|
||||||
</NavLink>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
|
|
||||||
<LogOut className="h-4 w-4" /> Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
||||||
</header>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,26 +194,20 @@ export default function App() {
|
|||||||
const admin = user.is_admin
|
const admin = user.is_admin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/20">
|
<Shell username={user.username} isAdmin={admin}>
|
||||||
<Nav username={user.username} isAdmin={admin} />
|
<Routes>
|
||||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
<Route path="/clients" element={<Clients />} />
|
||||||
<Routes>
|
<Route path="/devices" element={<Devices />} />
|
||||||
<Route path="/clients" element={<Clients />} />
|
<Route path="/vlans" element={<Vlans />} />
|
||||||
<Route path="/devices" element={<Devices />} />
|
<Route path="/account" element={<ChangePassword />} />
|
||||||
<Route path="/vlans" element={<Vlans />} />
|
<Route path="/users" element={admin ? <Users /> : <Navigate to="/clients" replace />} />
|
||||||
<Route path="/account" element={<ChangePassword />} />
|
<Route
|
||||||
<Route
|
path="/apikeys"
|
||||||
path="/users"
|
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
|
||||||
element={admin ? <Users /> : <Navigate to="/clients" replace />}
|
/>
|
||||||
/>
|
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
|
||||||
<Route
|
<Route path="*" element={<Navigate to="/clients" replace />} />
|
||||||
path="/apikeys"
|
</Routes>
|
||||||
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
|
</Shell>
|
||||||
/>
|
|
||||||
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
|
|
||||||
<Route path="*" element={<Navigate to="/clients" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -111,6 +111,11 @@ export interface ClientEdit {
|
|||||||
alias?: string
|
alias?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ClientFilters {
|
||||||
|
search?: string
|
||||||
|
status?: ClientStatus
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientImportRow {
|
export interface ClientImportRow {
|
||||||
mac_address?: string
|
mac_address?: string
|
||||||
group?: string
|
group?: string
|
||||||
@@ -260,8 +265,12 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clients: {
|
clients: {
|
||||||
list: (limit = 50, offset = 0) =>
|
list: (limit = 50, offset = 0, filters: ClientFilters = {}) => {
|
||||||
request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||||
|
if (filters.search?.trim()) params.set('search', filters.search.trim())
|
||||||
|
if (filters.status) params.set('status', filters.status)
|
||||||
|
return request<Page<Client>>(`/client/?${params.toString()}`)
|
||||||
|
},
|
||||||
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
|
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
|
||||||
add: (body: ClientCreate) =>
|
add: (body: ClientCreate) =>
|
||||||
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
|
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
import { Loader2, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
|
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
|
||||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||||
@@ -51,6 +51,13 @@ export function Clients() {
|
|||||||
const [editing, setEditing] = useState<Client | null>(null)
|
const [editing, setEditing] = useState<Client | null>(null)
|
||||||
const [deleting, setDeleting] = useState<Client | null>(null)
|
const [deleting, setDeleting] = useState<Client | null>(null)
|
||||||
|
|
||||||
|
// Filters — `search` is the raw input; `debouncedSearch` is what we query with
|
||||||
|
// so we don't fire a request on every keystroke.
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
|
const [status, setStatus] = useState<ClientStatus | 'all'>('all')
|
||||||
|
const hasFilters = debouncedSearch.trim() !== '' || status !== 'all'
|
||||||
|
|
||||||
const [pageSize, setPageSize] = usePageSize()
|
const [pageSize, setPageSize] = usePageSize()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
@@ -58,12 +65,20 @@ export function Clients() {
|
|||||||
// until at least one VLAN exists.
|
// until at least one VLAN exists.
|
||||||
const noVlans = !loading && vlans.length === 0
|
const noVlans = !loading && vlans.length === 0
|
||||||
|
|
||||||
// Server-side pagination: fetch only the current page's rows.
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedSearch(search), 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
// Server-side pagination + filtering: fetch only the current page's rows.
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [d, v] = await Promise.all([
|
const [d, v] = await Promise.all([
|
||||||
api.clients.list(pageSize, (page - 1) * pageSize),
|
api.clients.list(pageSize, (page - 1) * pageSize, {
|
||||||
|
search: debouncedSearch,
|
||||||
|
status: status === 'all' ? undefined : status,
|
||||||
|
}),
|
||||||
api.vlans.list(),
|
api.vlans.list(),
|
||||||
])
|
])
|
||||||
setDevices(d.items)
|
setDevices(d.items)
|
||||||
@@ -75,14 +90,14 @@ export function Clients() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [page, pageSize])
|
}, [page, pageSize, debouncedSearch, status])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
// Reset to the first page when the page size changes.
|
// Reset to the first page when the page size or an active filter changes.
|
||||||
useEffect(() => setPage(1), [pageSize])
|
useEffect(() => setPage(1), [pageSize, debouncedSearch, status])
|
||||||
// Clamp the page when the total shrinks (e.g. after a delete).
|
// Clamp the page when the total shrinks (e.g. after a delete).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page > pageCount) setPage(pageCount)
|
if (page > pageCount) setPage(pageCount)
|
||||||
@@ -93,7 +108,9 @@ export function Clients() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||||
<p className="text-sm text-muted-foreground">{total} registered</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{total} {hasFilters ? 'match' : 'registered'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
@@ -110,6 +127,52 @@ export function Clients() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by MAC, name, phone or alias…"
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus | 'all')}>
|
||||||
|
<SelectTrigger className="sm:w-40">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setSearch('')
|
||||||
|
setStatus('all')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{noVlans && (
|
{noVlans && (
|
||||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||||
No VLANs exist yet. <Link to="/vlans" className="font-medium underline">Add a VLAN</Link>{' '}
|
No VLANs exist yet. <Link to="/vlans" className="font-medium underline">Add a VLAN</Link>{' '}
|
||||||
@@ -140,7 +203,9 @@ export function Clients() {
|
|||||||
) : devices.length === 0 ? (
|
) : devices.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
No devices yet. Add one to get started.
|
{hasFilters
|
||||||
|
? 'No clients match your filters.'
|
||||||
|
: 'No devices yet. Add one to get started.'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user