This commit is contained in:
+19
-9
@@ -1,8 +1,9 @@
|
||||
# radui — RADIUS Admin Portal
|
||||
|
||||
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **devices** and
|
||||
**VLANs** without touching SQL. Auth is an **API key** entered on a login screen and
|
||||
stored in the browser (`localStorage`), sent as `X-API-Key` on every request.
|
||||
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
|
||||
(customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs**
|
||||
without touching SQL. Auth is an **API key** entered on a login screen and stored in
|
||||
the browser (`localStorage`), sent as `X-API-Key` on every request.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -55,19 +56,28 @@ src/
|
||||
main.tsx providers (router, auth, toaster)
|
||||
App.tsx auth gate + nav + routes
|
||||
auth/auth.tsx API-key auth context (login/logout, 401 handling)
|
||||
lib/api.ts typed API client (device + vlan) + error handling
|
||||
lib/api.ts typed API client (client + device + vlan) + error handling
|
||||
lib/utils.ts cn() helper
|
||||
pages/
|
||||
Login.tsx API-key login screen
|
||||
Devices.tsx device list + add/edit/delete dialogs
|
||||
Clients.tsx client list + add/edit/delete + CSV import/export dialogs
|
||||
Devices.tsx NAS device list + editable alias
|
||||
Vlans.tsx VLAN list + add/rename/delete dialogs
|
||||
components/ui/ button, input, label, dialog, select, table, card, badge, sonner
|
||||
components/
|
||||
ClientImportExport.tsx CSV import (preview/confirm) + export dialog
|
||||
ui/ button, input, label, dialog, select, table, card, badge, sonner
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **Add device** requires MAC, group (VLAN), name, phone; alias optional. The group
|
||||
dropdown is populated from `GET /vlan/`.
|
||||
- **Edit device** can change group, status, name, phone, alias (any subset).
|
||||
- **Clients** — add requires MAC, group (VLAN), name, phone; alias optional. The
|
||||
group dropdown is populated from `GET /vlan/`. Edit changes any subset of group,
|
||||
status, name, phone, alias.
|
||||
- **CSV import** parses the file client-side, does a dry-run against
|
||||
`POST /client/import` to preview valid/error rows, and only writes on confirm.
|
||||
Export downloads all clients as CSV.
|
||||
- **Devices** — lists NAS/AP boxes from accounting, one row per **AP MAC** (with its
|
||||
NAS IP and all SSIDs — extra SSIDs collapse to `SSID1 (and N more…)` on hover). The
|
||||
only editable field is the human `alias` (`PUT /device/{ap_mac}`).
|
||||
- Validation/`4xx` errors from the API surface as toasts with the server's message
|
||||
(422 field errors are flattened to `field: message`).
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { LogOut, Moon, Router, Sun, Wifi } from 'lucide-react'
|
||||
import { LogOut, Moon, Router, Sun, Users, Wifi } 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 { Button } from '@/components/ui/button'
|
||||
@@ -39,6 +40,9 @@ function Nav() {
|
||||
<Wifi className="h-5 w-5 text-primary" />
|
||||
RADIUS Admin
|
||||
</div>
|
||||
<NavLink to="/clients" className={linkClass}>
|
||||
<Users className="h-4 w-4" /> Clients
|
||||
</NavLink>
|
||||
<NavLink to="/devices" className={linkClass}>
|
||||
<Router className="h-4 w-4" /> Devices
|
||||
</NavLink>
|
||||
@@ -66,9 +70,10 @@ export default function App() {
|
||||
<Nav />
|
||||
<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="*" element={<Navigate to="/devices" replace />} />
|
||||
<Route path="*" element={<Navigate to="/clients" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+21
-21
@@ -5,9 +5,9 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type Device,
|
||||
type DeviceImportResult,
|
||||
type DeviceImportRow,
|
||||
type Client,
|
||||
type ClientImportResult,
|
||||
type ClientImportRow,
|
||||
} from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -40,7 +40,7 @@ function downloadCsv(filename: string, csv: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function parseCsv(file: File): Promise<DeviceImportRow[]> {
|
||||
function parseCsv(file: File): Promise<ClientImportRow[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Papa.parse<Record<string, string>>(file, {
|
||||
header: true,
|
||||
@@ -64,10 +64,10 @@ function parseCsv(file: File): Promise<DeviceImportRow[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
export function ClientImportExport({ onImported }: { onImported: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [rows, setRows] = useState<DeviceImportRow[] | null>(null)
|
||||
const [preview, setPreview] = useState<DeviceImportResult | null>(null)
|
||||
const [rows, setRows] = useState<ClientImportRow[] | null>(null)
|
||||
const [preview, setPreview] = useState<ClientImportResult | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
@@ -92,7 +92,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
toast.error('No rows found in that CSV')
|
||||
return
|
||||
}
|
||||
const result = await api.devices.import(parsed, true) // dry run
|
||||
const result = await api.clients.import(parsed, true) // dry run
|
||||
setRows(parsed)
|
||||
setPreview(result)
|
||||
} catch (err) {
|
||||
@@ -108,10 +108,10 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
if (!rows) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.devices.import(rows, false)
|
||||
const res = await api.clients.import(rows, false)
|
||||
const skipped = res.errors.length
|
||||
toast.success(
|
||||
`Imported ${res.created} device${res.created === 1 ? '' : 's'}` +
|
||||
`Imported ${res.created} client${res.created === 1 ? '' : 's'}` +
|
||||
(skipped ? `, ${skipped} skipped` : ''),
|
||||
)
|
||||
onImported()
|
||||
@@ -127,11 +127,11 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
async function exportAll() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const all: Device[] = []
|
||||
const all: Client[] = []
|
||||
const limit = 500
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
const page = await api.devices.list(limit, offset)
|
||||
const page = await api.clients.list(limit, offset)
|
||||
all.push(...page.items)
|
||||
offset += page.items.length
|
||||
if (page.items.length === 0 || all.length >= page.total) break
|
||||
@@ -147,8 +147,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
d.status ?? '',
|
||||
]),
|
||||
})
|
||||
downloadCsv('devices.csv', csv)
|
||||
toast.success(`Exported ${all.length} device${all.length === 1 ? '' : 's'}`)
|
||||
downloadCsv('clients.csv', csv)
|
||||
toast.success(`Exported ${all.length} client${all.length === 1 ? '' : 's'}`)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Export failed')
|
||||
@@ -166,9 +166,9 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import / Export devices</DialogTitle>
|
||||
<DialogTitle>Import / Export clients</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bulk-add devices from a CSV, or download all devices as a CSV.
|
||||
Bulk-add clients from a CSV, or download all clients as a CSV.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('devices-template.csv', EXAMPLE_CSV)}
|
||||
onClick={() => downloadCsv('clients-template.csv', EXAMPLE_CSV)}
|
||||
>
|
||||
<FileText className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
@@ -231,8 +231,8 @@ export function DeviceImportExport({ onImported }: { onImported: () => void }) {
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Export all devices</div>
|
||||
<div className="text-muted-foreground">Download every device as a CSV file.</div>
|
||||
<div className="font-medium">Export all clients</div>
|
||||
<div className="text-muted-foreground">Download every client as a CSV file.</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={exportAll} disabled={exporting}>
|
||||
{exporting ? (
|
||||
@@ -258,7 +258,7 @@ function ImportPreview({
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
preview: DeviceImportResult
|
||||
preview: ClientImportResult
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onBack: () => void
|
||||
@@ -310,7 +310,7 @@ function ImportPreview({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasErrors
|
||||
? `Add the ${preview.valid} valid row${preview.valid === 1 ? '' : 's'} anyway, or go back and fix the file.`
|
||||
: `All rows are valid. Import ${preview.valid} device${preview.valid === 1 ? '' : 's'}?`}
|
||||
: `All rows are valid. Import ${preview.valid} client${preview.valid === 1 ? '' : 's'}?`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
+39
-20
@@ -80,18 +80,18 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
// ---- Types ----
|
||||
export type DeviceStatus = 'new' | 'paid' | 'unpaid'
|
||||
export type ClientStatus = 'new' | 'paid' | 'unpaid'
|
||||
|
||||
export interface Device {
|
||||
export interface Client {
|
||||
mac_address: string
|
||||
group: string | null
|
||||
status: DeviceStatus | null
|
||||
status: ClientStatus | null
|
||||
name: string | null
|
||||
phone: string | null
|
||||
alias: string | null
|
||||
}
|
||||
|
||||
export interface DeviceCreate {
|
||||
export interface ClientCreate {
|
||||
mac_address: string
|
||||
group: string
|
||||
name: string
|
||||
@@ -99,16 +99,16 @@ export interface DeviceCreate {
|
||||
alias?: string | null
|
||||
}
|
||||
|
||||
export interface DeviceEdit {
|
||||
export interface ClientEdit {
|
||||
mac_address: string
|
||||
group?: string
|
||||
status?: DeviceStatus
|
||||
status?: ClientStatus
|
||||
name?: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface DeviceImportRow {
|
||||
export interface ClientImportRow {
|
||||
mac_address?: string
|
||||
group?: string
|
||||
name?: string
|
||||
@@ -116,18 +116,26 @@ export interface DeviceImportRow {
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface DeviceImportRowError {
|
||||
export interface ClientImportRowError {
|
||||
row: number
|
||||
mac: string | null
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DeviceImportResult {
|
||||
export interface ClientImportResult {
|
||||
total: number
|
||||
valid: number
|
||||
created: number
|
||||
dry_run: boolean
|
||||
errors: DeviceImportRowError[]
|
||||
errors: ClientImportRowError[]
|
||||
}
|
||||
|
||||
// A NAS/AP device seen in radacct (keyed by AP MAC), with an editable alias.
|
||||
export interface Device {
|
||||
ap_mac: string
|
||||
nasipaddress: string | null
|
||||
ssids: string[]
|
||||
alias: string | null
|
||||
}
|
||||
|
||||
export interface Vlan {
|
||||
@@ -147,25 +155,36 @@ export const api = {
|
||||
// health check to validate the API key on login
|
||||
ping: () => request<unknown>('/vlan/'),
|
||||
|
||||
devices: {
|
||||
clients: {
|
||||
list: (limit = 50, offset = 0) =>
|
||||
request<Page<Device>>(`/device/?limit=${limit}&offset=${offset}`),
|
||||
get: (mac: string) => request<Device>(`/device/${encodeURIComponent(mac)}`),
|
||||
add: (body: DeviceCreate) =>
|
||||
request<Device>('/device/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||
edit: (body: DeviceEdit) =>
|
||||
request<Device>('/device/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||
request<Page<Client>>(`/client/?limit=${limit}&offset=${offset}`),
|
||||
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
|
||||
add: (body: ClientCreate) =>
|
||||
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||
edit: (body: ClientEdit) =>
|
||||
request<Client>('/client/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||
remove: (mac: string) =>
|
||||
request<void>(`/device/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
|
||||
request<void>(`/client/${encodeURIComponent(mac)}`, { method: 'DELETE' }),
|
||||
// Bulk import. dryRun=true validates only (nothing written) so the UI can
|
||||
// preview errors and confirm; dryRun=false inserts the valid rows.
|
||||
import: (devices: DeviceImportRow[], dryRun: boolean) =>
|
||||
request<DeviceImportResult>('/device/import', {
|
||||
import: (devices: ClientImportRow[], dryRun: boolean) =>
|
||||
request<ClientImportResult>('/client/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ devices, dry_run: dryRun }),
|
||||
}),
|
||||
},
|
||||
|
||||
// NAS/AP boxes seen in radacct, keyed by AP MAC, with an editable human alias
|
||||
// stored in radadmin_devices.
|
||||
devices: {
|
||||
list: () => request<Device[]>('/device/'),
|
||||
setAlias: (apMac: string, alias: string | null) =>
|
||||
request<Device>(`/device/${encodeURIComponent(apMac)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ alias }),
|
||||
}),
|
||||
},
|
||||
|
||||
vlans: {
|
||||
list: () => request<Vlan[]>('/vlan/'),
|
||||
add: (vlanid: number, alias: string) =>
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } 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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ClientImportExport } from '@/components/ClientImportExport'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const STATUSES: ClientStatus[] = ['new', 'paid', 'unpaid']
|
||||
|
||||
function StatusBadge({ status }: { status: ClientStatus | null }) {
|
||||
if (!status) return <span className="text-muted-foreground">—</span>
|
||||
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
|
||||
return <Badge variant={variant}>{status}</Badge>
|
||||
}
|
||||
|
||||
export function Clients() {
|
||||
const [devices, setDevices] = useState<Client[]>([])
|
||||
const [vlans, setVlans] = useState<Vlan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Client | null>(null)
|
||||
const [deleting, setDeleting] = useState<Client | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [d, v] = await Promise.all([api.clients.list(200), api.vlans.list()])
|
||||
setDevices(d.items)
|
||||
setVlans(v)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Clients</h1>
|
||||
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<ClientImportExport onImported={load} />
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus /> Add device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>MAC address</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Phone</TableHead>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Alias</TableHead>
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
No devices yet. Add one to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
devices.map((d) => (
|
||||
<TableRow key={d.mac_address}>
|
||||
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
|
||||
<TableCell>{d.name ?? '—'}</TableCell>
|
||||
<TableCell>{d.phone ?? '—'}</TableCell>
|
||||
<TableCell>{d.group ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={d.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleting(d)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AddClientDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
vlans={vlans}
|
||||
onSaved={load}
|
||||
/>
|
||||
<EditClientDialog
|
||||
device={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
vlans={vlans}
|
||||
onSaved={load}
|
||||
/>
|
||||
<DeleteClientDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Add ----------
|
||||
function AddClientDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
vlans,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
vlans: Vlan[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [mac, setMac] = useState('')
|
||||
const [group, setGroup] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMac('')
|
||||
setGroup('')
|
||||
setName('')
|
||||
setPhone('')
|
||||
setAlias('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const phoneErr = phone.trim() ? phoneError(phone) : null
|
||||
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
|
||||
|
||||
async function submit() {
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.clients.add({
|
||||
mac_address: mac.trim(),
|
||||
group,
|
||||
name: name.trim(),
|
||||
phone: normalizePhone(phone) ?? phone.trim(),
|
||||
alias: alias.trim() || null,
|
||||
})
|
||||
toast.success(`Client ${mac.trim()} added`)
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add device')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add device</DialogTitle>
|
||||
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field label="MAC address" required>
|
||||
<Input
|
||||
placeholder="AA-BB-CC-DD-EE-FF"
|
||||
className="font-mono"
|
||||
value={mac}
|
||||
onChange={(e) => setMac(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Group (VLAN)" required>
|
||||
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone" required error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Device alias (optional)">
|
||||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Add device
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Edit ----------
|
||||
function EditClientDialog({
|
||||
device,
|
||||
onClose,
|
||||
vlans,
|
||||
onSaved,
|
||||
}: {
|
||||
device: Client | null
|
||||
onClose: () => void
|
||||
vlans: Vlan[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [group, setGroup] = useState('')
|
||||
const [status, setStatus] = useState<ClientStatus>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (device) {
|
||||
setGroup(device.group ?? '')
|
||||
setStatus(device.status ?? 'new')
|
||||
setName(device.name ?? '')
|
||||
setPhone(device.phone ?? '')
|
||||
setAlias(device.alias ?? '')
|
||||
}
|
||||
}, [device])
|
||||
|
||||
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
|
||||
|
||||
async function submit() {
|
||||
if (!device || phoneErr) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.clients.edit({
|
||||
mac_address: device.mac_address,
|
||||
group: group || undefined,
|
||||
status,
|
||||
name,
|
||||
phone: normalizePhone(phone) ?? phone,
|
||||
alias,
|
||||
})
|
||||
toast.success(`Client ${device.mac_address} updated`)
|
||||
onClose()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update device')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit device</DialogTitle>
|
||||
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Group (VLAN)">
|
||||
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone" error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Device alias">
|
||||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !!phoneErr}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
function DeleteClientDialog({
|
||||
device,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
device: Client | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function confirm() {
|
||||
if (!device) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.clients.remove(device.mac_address)
|
||||
toast.success(`Client ${device.mac_address} deleted`)
|
||||
onClose()
|
||||
onDeleted()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete device?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
|
||||
radusergroup and customers. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- shared bits ----------
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSelect({
|
||||
vlans,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
vlans: Vlan[]
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a VLAN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vlans.map((v) => (
|
||||
<SelectItem key={v.vlanid} value={v.alias}>
|
||||
{v.alias} (VLAN {v.vlanid})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
+63
-349
@@ -1,11 +1,8 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, RefreshCw, Wifi } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api, ApiError, type Device, type DeviceStatus, type Vlan } from '@/lib/api'
|
||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||
import { api, ApiError, type Device } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DeviceImportExport } from '@/components/DeviceImportExport'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
@@ -24,36 +21,30 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const STATUSES: DeviceStatus[] = ['new', 'paid', 'unpaid']
|
||||
|
||||
function StatusBadge({ status }: { status: DeviceStatus | null }) {
|
||||
if (!status) return <span className="text-muted-foreground">—</span>
|
||||
const variant = status === 'paid' ? 'success' : status === 'unpaid' ? 'destructive' : 'secondary'
|
||||
return <Badge variant={variant}>{status}</Badge>
|
||||
function Ssids({ ssids }: { ssids: string[] }) {
|
||||
if (ssids.length === 0) return <span className="text-muted-foreground">—</span>
|
||||
const extra = ssids.length - 1
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5" title={ssids.join(', ')}>
|
||||
<Wifi className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{ssids[0]}
|
||||
{extra > 0 && (
|
||||
<span className="text-muted-foreground">(and {extra} more…)</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Devices() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [vlans, setVlans] = useState<Vlan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Device | null>(null)
|
||||
const [deleting, setDeleting] = useState<Device | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [d, v] = await Promise.all([api.devices.list(200), api.vlans.list()])
|
||||
setDevices(d.items)
|
||||
setVlans(v)
|
||||
setDevices(await api.devices.list())
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load devices')
|
||||
@@ -71,71 +62,54 @@ export function Devices() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Devices</h1>
|
||||
<p className="text-sm text-muted-foreground">{devices.length} registered</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<DeviceImportExport onImported={load} />
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus /> Add device
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{devices.length} NAS/AP {devices.length === 1 ? 'device' : 'devices'} seen in accounting
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>MAC address</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Phone</TableHead>
|
||||
<TableHead>Group</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>AP MAC</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>SSIDs</TableHead>
|
||||
<TableHead>Alias</TableHead>
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
<TableHead className="w-16 text-right">Edit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
No devices yet. Add one to get started.
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
No devices have reported accounting yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
devices.map((d) => (
|
||||
<TableRow key={d.mac_address}>
|
||||
<TableCell className="font-mono text-xs">{d.mac_address}</TableCell>
|
||||
<TableCell>{d.name ?? '—'}</TableCell>
|
||||
<TableCell>{d.phone ?? '—'}</TableCell>
|
||||
<TableCell>{d.group ?? <span className="text-muted-foreground">—</span>}</TableCell>
|
||||
<TableRow key={d.ap_mac}>
|
||||
<TableCell className="font-mono text-xs">{d.ap_mac}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{d.nasipaddress ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={d.status} />
|
||||
<Ssids ssids={d.ssids} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{d.alias ?? <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{d.alias ?? '—'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleting(d)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit alias">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -144,257 +118,38 @@ export function Devices() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AddDeviceDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
vlans={vlans}
|
||||
onSaved={load}
|
||||
/>
|
||||
<EditDeviceDialog
|
||||
device={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
vlans={vlans}
|
||||
onSaved={load}
|
||||
/>
|
||||
<DeleteDeviceDialog device={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||
<EditAliasDialog device={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Add ----------
|
||||
function AddDeviceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
vlans,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
vlans: Vlan[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [mac, setMac] = useState('')
|
||||
const [group, setGroup] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMac('')
|
||||
setGroup('')
|
||||
setName('')
|
||||
setPhone('')
|
||||
setAlias('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const phoneErr = phone.trim() ? phoneError(phone) : null
|
||||
const valid = mac.trim() && group && name.trim() && phone.trim() && !phoneErr
|
||||
|
||||
async function submit() {
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.devices.add({
|
||||
mac_address: mac.trim(),
|
||||
group,
|
||||
name: name.trim(),
|
||||
phone: normalizePhone(phone) ?? phone.trim(),
|
||||
alias: alias.trim() || null,
|
||||
})
|
||||
toast.success(`Device ${mac.trim()} added`)
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add device')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add device</DialogTitle>
|
||||
<DialogDescription>Register a new MAC address and assign it to a VLAN group.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field label="MAC address" required>
|
||||
<Input
|
||||
placeholder="AA-BB-CC-DD-EE-FF"
|
||||
className="font-mono"
|
||||
value={mac}
|
||||
onChange={(e) => setMac(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Group (VLAN)" required>
|
||||
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone" required error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Device alias (optional)">
|
||||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Add device
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Edit ----------
|
||||
function EditDeviceDialog({
|
||||
function EditAliasDialog({
|
||||
device,
|
||||
onClose,
|
||||
vlans,
|
||||
onSaved,
|
||||
}: {
|
||||
device: Device | null
|
||||
onClose: () => void
|
||||
vlans: Vlan[]
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [group, setGroup] = useState('')
|
||||
const [status, setStatus] = useState<DeviceStatus>('new')
|
||||
const [name, setName] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (device) {
|
||||
setGroup(device.group ?? '')
|
||||
setStatus(device.status ?? 'new')
|
||||
setName(device.name ?? '')
|
||||
setPhone(device.phone ?? '')
|
||||
setAlias(device.alias ?? '')
|
||||
}
|
||||
if (device) setAlias(device.alias ?? '')
|
||||
}, [device])
|
||||
|
||||
const phoneErr = phone.trim() ? phoneError(phone) : 'Phone is required'
|
||||
|
||||
async function submit() {
|
||||
if (!device || phoneErr) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.devices.edit({
|
||||
mac_address: device.mac_address,
|
||||
group: group || undefined,
|
||||
status,
|
||||
name,
|
||||
phone: normalizePhone(phone) ?? phone,
|
||||
alias,
|
||||
})
|
||||
toast.success(`Device ${device.mac_address} updated`)
|
||||
onClose()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update device')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit device</DialogTitle>
|
||||
<DialogDescription className="font-mono">{device?.mac_address}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Group (VLAN)">
|
||||
<GroupSelect vlans={vlans} value={group} onChange={setGroup} />
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as DeviceStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Name">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Phone" error={phoneErr}>
|
||||
<Input
|
||||
placeholder="9XXXXXX"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Device alias">
|
||||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !!phoneErr}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
function DeleteDeviceDialog({
|
||||
device,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
device: Device | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function confirm() {
|
||||
if (!device) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.devices.remove(device.mac_address)
|
||||
toast.success(`Device ${device.mac_address} deleted`)
|
||||
const value = alias.trim() || null
|
||||
await api.devices.setAlias(device.ap_mac, value)
|
||||
toast.success(`Alias ${value ? 'saved' : 'cleared'} for ${device.ap_mac}`)
|
||||
onClose()
|
||||
onDeleted()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete device')
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save alias')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -404,71 +159,30 @@ function DeleteDeviceDialog({
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete device?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes <span className="font-mono">{device?.mac_address}</span> from radcheck,
|
||||
radusergroup and customers. This cannot be undone.
|
||||
</DialogDescription>
|
||||
<DialogTitle>Edit device alias</DialogTitle>
|
||||
<DialogDescription className="font-mono">{device?.ap_mac}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Alias</Label>
|
||||
<Input
|
||||
placeholder="e.g. Lobby AP"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to clear the alias.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- shared bits ----------
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
error,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupSelect({
|
||||
vlans,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
vlans: Vlan[]
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a VLAN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vlans.map((v) => (
|
||||
<SelectItem key={v.vlanid} value={v.alias}>
|
||||
{v.alias} (VLAN {v.vlanid})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user