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 {
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Router,
|
||||
ScrollText,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
UserCog,
|
||||
Users as UsersIcon,
|
||||
Wifi,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
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 linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
'inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
|
||||
)
|
||||
|
||||
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 (
|
||||
<header className="border-b bg-background">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center gap-1 px-4">
|
||||
<div className="mr-4 flex items-center gap-2 font-semibold">
|
||||
<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>
|
||||
<NavLink to="/clients" className={linkClass}>
|
||||
<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>
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" /> Sign out
|
||||
</Button>
|
||||
</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>
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,26 +194,20 @@ export default function App() {
|
||||
const admin = user.is_admin
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
<Nav username={user.username} isAdmin={admin} />
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
+11
-2
@@ -111,6 +111,11 @@ export interface ClientEdit {
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface ClientFilters {
|
||||
search?: string
|
||||
status?: ClientStatus
|
||||
}
|
||||
|
||||
export interface ClientImportRow {
|
||||
mac_address?: string
|
||||
group?: string
|
||||
@@ -260,8 +265,12 @@ export const api = {
|
||||
},
|
||||
|
||||
clients: {
|
||||
list: (limit = 50, offset = 0) =>
|
||||
request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
|
||||
list: (limit = 50, offset = 0, filters: ClientFilters = {}) => {
|
||||
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)}`),
|
||||
add: (body: ClientCreate) =>
|
||||
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
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 { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
|
||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||
@@ -51,6 +51,13 @@ export function Clients() {
|
||||
const [editing, setEditing] = 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 [page, setPage] = useState(1)
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
@@ -58,12 +65,20 @@ export function Clients() {
|
||||
// until at least one VLAN exists.
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
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(),
|
||||
])
|
||||
setDevices(d.items)
|
||||
@@ -75,14 +90,14 @@ export function Clients() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize])
|
||||
}, [page, pageSize, debouncedSearch, status])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Reset to the first page when the page size changes.
|
||||
useEffect(() => setPage(1), [pageSize])
|
||||
// Reset to the first page when the page size or an active filter changes.
|
||||
useEffect(() => setPage(1), [pageSize, debouncedSearch, status])
|
||||
// Clamp the page when the total shrinks (e.g. after a delete).
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount)
|
||||
@@ -93,7 +108,9 @@ export function Clients() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
@@ -110,6 +127,52 @@ export function Clients() {
|
||||
</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 && (
|
||||
<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>{' '}
|
||||
@@ -140,7 +203,9 @@ export function Clients() {
|
||||
) : devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<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>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user