refactor code, new db tables, show AP info on UI
build-and-push / build (push) Failing after 34s

This commit is contained in:
2026-08-01 02:44:50 +05:00
parent a6d4fa1f6d
commit db140912f5
13 changed files with 1103 additions and 660 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ FreeRADIUS schema and a web admin portal for it.
| Dir | What | Stack |
|-------------|----------------------------------------|--------------------------------------------|
| `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 |
| `frontend/` | Admin UI for devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 |
| `frontend/` | Admin UI for clients, devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 |
Auth across both is a shared secret sent in the **`X-API-Key`** header — the
frontend collects the key on a login screen and the backend validates it.
+110 -38
View File
@@ -4,6 +4,17 @@ A FastAPI service exposing RESTful CRUD over the FreeRADIUS MySQL/MariaDB schema
(`customers`, `radcheck`, `radreply`, `radgroupreply`/vlans, `radusergroup`, `nas`,
plus read-only `radacct`, `radpostauth`, `nasreload`).
Two endpoints are **logical views** layered over that schema rather than raw tables:
- **Clients** (`/client`) — customer devices keyed by MAC, spanning `radcheck` +
`radusergroup` + `customers` (status), with human metadata (name/phone/alias) in
the standalone **`radadmin_clients`** table.
- **Devices** (`/device`) — the NAS/AP boxes seen in `radacct`, keyed by **AP MAC**
(from `calledstationid`), with a human alias in the standalone
**`radadmin_devices`** table.
The two `radadmin_*` tables hold only human labels — RADIUS never reads them.
## Stack
- **FastAPI** + **Uvicorn** (ASGI)
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
@@ -57,8 +68,9 @@ Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open.
## UI integration guide
For a management portal the two primary resources are **Devices** (`/device`) and
**VLANs** (`/vlan`). Everything else is lower-level raw-table access.
For a management portal the primary resources are **Clients** (`/client`),
**Devices** (`/device`), and **VLANs** (`/vlan`). Everything else is lower-level
raw-table access.
**Base URL (staging):** `http://10.0.1.235:8000`
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
@@ -66,7 +78,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
### Response shapes
`GET /device/`**paginated** envelope:
`GET /client/`**paginated** envelope:
```json
{
@@ -80,13 +92,20 @@ For a management portal the two primary resources are **Devices** (`/device`) an
}
```
`GET /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object:
`GET /client/{mac}`, `POST /client/add`, `POST /client/edit` — a single client object:
```json
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
```
`GET /device/`**plain array** of NAS devices (not paginated):
```json
[ { "ap_mac": "3C-52-A1-93-06-FC", "nasipaddress": "192.168.1.102",
"ssids": ["VSARLINK", "Guest"], "alias": "Lobby AP" } ]
```
`GET /vlan/`**plain array** (not paginated):
```json
@@ -102,7 +121,7 @@ For a management portal the two primary resources are **Devices** (`/device`) an
Application errors (`400`, `401`, `404`, `409`) return a **string** detail:
```json
{ "detail": "Device 'AA-BB-CC-DD-EE-11' already exists" }
{ "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" }
```
Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail:
@@ -116,12 +135,15 @@ each entry's `msg` (and `loc`) for field-level messages.
### Typical portal flows
- **Provision a device:** `POST /device/add` with `mac_address`, `group` (an
- **Provision a client:** `POST /client/add` with `mac_address`, `group` (an
existing VLAN alias), `name`, `phone`, optional `alias`. Handle `400` (group
missing), `409` (MAC exists), `422` (bad MAC / missing name·phone).
- **Change plan state:** `POST /device/edit` `{mac_address, status}`.
- **Move to another VLAN:** `POST /device/edit` `{mac_address, group}`.
- **Change plan state:** `POST /client/edit` `{mac_address, status}`.
- **Move to another VLAN:** `POST /client/edit` `{mac_address, group}`.
- **Bulk import clients:** `POST /client/import` `{devices:[...], dry_run}` — see
the Clients section.
- **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`).
- **Label a NAS device:** `GET /device/` to list them, `PUT /device/{ip}` `{alias}`.
## Endpoints
@@ -141,7 +163,8 @@ per-resource filters:
| Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
| **VLANs** | `/vlan` | see below | — |
| **Devices** | `/device` | see below | — |
| **Clients** | `/client` | see below | — |
| **Devices** | `/device` | GET (list), PUT (alias) | — |
| User↔group | `/radusergroup` | GET, POST, PUT, DELETE | `username`, `groupname` |
| Accounting | `/radacct` | GET (read-only) | `username`, `nasipaddress`, `active` |
| Post-auth log | `/radpostauth` | GET (read-only) | `username`, `reply` |
@@ -169,37 +192,65 @@ and `Tunnel-Private-Group-Id=<vlanid>`.
- Renaming updates `radgroupreply.groupname` only; if you also map users to groups
in `radusergroup`, update those separately.
### Devices — `/device`
### Clients — `/client`
A device is a client identified by its MAC address (used as the RADIUS `username`).
One device spans three tables: `radcheck` (MAC = password), `radusergroup` (group
membership), and `customers` (status + human metadata). MAC input is normalized to
uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and lowercase are accepted.
A client is identified by its MAC address (used as the RADIUS `username`). One
client spans four tables: `radcheck` (MAC = password), `radusergroup` (group
membership), `customers` (status), and `radadmin_clients` (human metadata). MAC
input is normalized to uppercase, hyphen-separated (`AA-BB-CC-DD-EE-FF`); colons and
lowercase are accepted.
The `customers` table also carries **human-only metadata that RADIUS never reads**:
`name`, `phone`, and `device_alias`. These are collected at device creation.
The **`radadmin_clients`** table carries **human-only metadata that RADIUS never
reads** — `name`, `phone`, and `alias`, keyed by `mac_address`. These are collected
at client creation.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
| Get one device | `GET /device/{mac_address}` |
| Add a device | `POST /device/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` |
| Edit a device | `POST /device/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` |
| Delete a device | `DELETE /device/{mac_address}` (removes all 3 rows) |
| List clients | `GET /client/``{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
| Get one client | `GET /client/{mac_address}` |
| Add a client | `POST /client/add` body `{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}` |
| Edit a client | `POST /client/edit` body `{"mac_address":"...", group?, status?, name?, phone?, alias?}` |
| Import clients | `POST /client/import` body `{"devices":[{...}], "dry_run":true}` (bulk CSV import) |
| Delete a client | `DELETE /client/{mac_address}` (removes all rows) |
On **add**, `name` and `phone` are **required**; `alias` is **optional**. They are
stored in `customers.name`, `customers.phone`, `customers.device_alias` and returned
on every device response (`alias` mirrors the `device_alias` column).
stored in `radadmin_clients` (`name`, `phone`, `alias`) and returned on every client
response.
- **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a
`radusergroup` row (`priority 1`), and a `customers` row (`status = paid`). The
`group` must already exist in `radgroupreply` or you get `400`.
`radusergroup` row (`priority 1`), a `customers` row (`status = paid`), and a
`radadmin_clients` row (name/phone/alias). The `group` must already exist in
`radgroupreply` or you get `400`.
- **Edit** accepts any subset of `group`, `status`, `name`, `phone`, `alias` (at
least one required); omitted fields are left unchanged. A new `group` must exist
in `radgroupreply` (`400` otherwise). `status` must be one of `new` / `paid` /
`unpaid`. In the DB the group is stored in the `radusergroup.groupname` column and
`alias` in `customers.device_alias`.
- Adding a device whose MAC already exists → `409`.
`unpaid`. In the DB the group is stored in the `radusergroup.groupname` column,
`status` in `customers.status`, and `name`/`phone`/`alias` in `radadmin_clients`.
- Adding a client whose MAC already exists → `409`.
- **Import** validates each row with the same rules as `add`; with `dry_run:true`
nothing is written and it returns `{total, valid, created:0, dry_run, errors:[{row,
mac, detail}]}` for a preview. With `dry_run:false` the valid rows are inserted
best-effort (invalid rows reported, not fatal). The JSON field is `devices` (rows).
### Devices — `/device`
A device here is a **NAS/AP box**, keyed by its **AP MAC** — not a customer MAC
(those are Clients). The list is derived from `radacct.calledstationid`
(`<AP-MAC>:<SSID>`): rows are grouped by the AP MAC, so one physical AP is a single
entry even when it broadcasts several SSIDs. Each entry aggregates every `ssid` seen
and the NAS IP(s) it reported from. An editable human `alias` is stored in the
standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS never reads.
| Action | Request |
|------------------|-----------------------------------------------------|
| List devices | `GET /device/``[{ap_mac, nasipaddress, ssids:[...], alias}]` |
| Set/clear alias | `PUT /device/{ap_mac}` body `{"alias":"Lobby AP"}` (`alias:null` clears) |
- List is a **plain array** (not paginated), ordered by AP MAC.
- `nasipaddress` is the NAS IP(s) the AP reports from (comma-joined if more than one);
`ssids` is the full list of SSIDs seen for that AP MAC.
- `PUT` upserts the `radadmin_devices` row for that AP MAC — no row need pre-exist;
the MAC is normalized to uppercase-hyphen form.
## Examples
@@ -231,27 +282,44 @@ curl http://10.0.1.235:8000/vlan/55 \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List devices
curl http://10.0.1.235:8000/device/ \
# List clients
curl http://10.0.1.235:8000/client/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Add a device (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/device/add \
# Add a client (MAC + existing group + name/phone required, alias optional)
curl http://10.0.1.235:8000/client/add \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali Hassan","phone":"7712345","alias":"Living Room TV"}' -s | jq
# Edit a device (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/device/edit \
# Edit a client (any subset of group/status/name/phone/alias)
curl http://10.0.1.235:8000/client/edit \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"mac_address":"14-99-3E-74-CB-7F","status":"unpaid","alias":"Living Room TV"}' -s | jq
# Delete a device
curl http://10.0.1.235:8000/device/14-99-3E-74-CB-7F \
# Bulk-import clients (dry-run — preview only, nothing written)
curl http://10.0.1.235:8000/client/import \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"dry_run":true,"devices":[{"mac_address":"14-99-3E-74-CB-7F","group":"staff","name":"Ali","phone":"7712345"}]}' -s | jq
# Delete a client
curl http://10.0.1.235:8000/client/14-99-3E-74-CB-7F \
-X DELETE \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# List NAS devices (IPs seen in radacct, with alias/AP-MAC/SSID)
curl http://10.0.1.235:8000/device/ \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
# Set a device alias by AP MAC (PUT; send {"alias":null} to clear)
curl http://10.0.1.235:8000/device/3C-52-A1-93-06-FC \
-X PUT \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
-H "Content-Type: application/json" \
-d '{"alias":"Lobby AP"}' -s | jq
# Create a customer
curl http://10.0.1.235:8000/customers \
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
@@ -282,7 +350,7 @@ curl http://10.0.1.235:8000/health -s | jq
| 204 | Deleted (no content) |
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
| 401 | Missing/invalid `X-API-Key` |
| 404 | Row / device / VLAN not found |
| 404 | Row / client / device / VLAN not found |
| 409 | Duplicate / integrity conflict |
| 422 | Request body failed validation |
@@ -297,7 +365,11 @@ app/
errors.py APIError + JSON handler
crud.py generic list/get/create/update/delete helpers
pagination.py Page envelope + limit/offset dependency
models.py SQLAlchemy models (one per table)
models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices)
schemas.py Pydantic request/response models
routers/ one module per resource
client.py Clients — MAC view over radcheck+radusergroup+customers
device.py Devices — AP MACs from radacct + radadmin_devices alias
vlan.py VLANs — view over radgroupreply
... customers, nas, radcheck/reply, radgroup*, radacct, ...
```
+2
View File
@@ -7,6 +7,7 @@ from .config import get_settings
from .database import engine
from .errors import APIError, api_error_handler
from .routers import (
client,
customers,
device,
nas,
@@ -69,6 +70,7 @@ for module in (
radgroupreply,
radusergroup,
vlan,
client,
device,
radacct,
radpostauth,
+26 -1
View File
@@ -13,9 +13,34 @@ class Customer(Base):
username: Mapped[str] = mapped_column(String(64), nullable=False)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
status: Mapped[str] = mapped_column(String(10), nullable=False, default="new")
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminClient(Base):
"""Human-only client metadata (name/phone/alias), keyed by MAC. RADIUS ignores this."""
__tablename__ = "radadmin_clients"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
name: Mapped[str | None] = mapped_column(String(128), nullable=True)
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
device_alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
class RadadminDevice(Base):
"""Human alias for a NAS/AP box, keyed by its AP MAC. RADIUS never reads this."""
__tablename__ = "radadmin_devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
mac_address: Mapped[str] = mapped_column(String(17), nullable=False, unique=True)
alias: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime | None] = mapped_column(
DateTime, nullable=True, server_default=func.current_timestamp()
)
+239
View File
@@ -0,0 +1,239 @@
"""Client management — a logical view spanning three tables.
A "client" is identified by its MAC address, which is used verbatim as the RADIUS
``username``. One client touches three tables:
radcheck username = MAC, Cleartext-Password := MAC (auth)
radusergroup username = MAC, groupname = <group> (VLAN/group membership)
customers username = MAC, mac_address = MAC, status (billing/metadata)
This router hides that fan-out behind mac_address + group + status.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import Customer, RadadminClient, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import (
ClientCreate,
ClientEdit,
ClientImportError,
ClientImportRequest,
ClientImportResult,
ClientOut,
)
router = APIRouter(prefix="/client", tags=["client"])
def _group_exists(db: Session, group: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1)
return db.execute(stmt).first() is not None
def _client_exists(db: Session, mac: str) -> bool:
stmt = select(Customer.id).where(Customer.username == mac).limit(1)
return db.execute(stmt).first() is not None
def _stage_client(db: Session, cli: ClientCreate) -> None:
"""Add a client's four rows to the session — radcheck + radusergroup (RADIUS),
customers (status), and radadmin_clients (name/phone/alias metadata).
Does not commit — the caller controls the transaction boundary.
"""
mac = cli.mac_address
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=cli.group, priority=1))
db.add(Customer(username=mac, mac_address=mac, status="paid"))
db.add(RadadminClient(mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias))
@router.get("/", response_model=Page[ClientOut])
def list_clients(page: PageParams = Depends(), db: Session = Depends(get_db)):
"""List clients — MAC, group and status, joined from customers + radusergroup."""
base = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
)
total = db.execute(select(func.count()).select_from(Customer)).scalar_one()
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all()
items = [
ClientOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al)
for mac, gn, st, nm, ph, al in rows
]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=ClientOut)
def get_client(mac_address: str, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-")
stmt = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.outerjoin(RadadminClient, RadadminClient.mac_address == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
return ClientOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=ClientOut, status_code=201)
def add_client(payload: ClientCreate, db: Session = Depends(get_db)):
"""Register a client: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _client_exists(db, mac):
raise APIError(status_code=409, detail=f"Client '{mac}' already exists")
_stage_client(db, payload)
db.commit()
return ClientOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
def _format_validation_error(exc: ValidationError) -> str:
"""Turn a pydantic ValidationError into a short, human message."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
return "; ".join(parts)
@router.post("/import", response_model=ClientImportResult)
def import_clients(payload: ClientImportRequest, db: Session = Depends(get_db)):
"""Bulk-import clients from parsed CSV rows.
Every row is validated with the same rules as ``/client/add`` (MAC/phone
normalization, required fields, group-exists, duplicate MAC — both against the
DB and within the file). Errors are collected per row rather than failing the
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
otherwise the valid rows are inserted best-effort (each in its own commit).
"""
errors: list[ClientImportError] = []
valid: list[tuple[int, ClientCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
cli = ClientCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(ClientImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = cli.mac_address
if mac in seen_macs:
errors.append(ClientImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, cli.group):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Group '{cli.group}' not found"))
continue
if _client_exists(db, mac):
errors.append(ClientImportError(row=idx, mac=mac, detail=f"Client '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, cli))
created = 0
if not payload.dry_run:
for idx, cli in valid:
_stage_client(db, cli)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(ClientImportError(row=idx, mac=cli.mac_address, detail="Insert failed (integrity error)"))
return ClientImportResult(
total=len(payload.devices),
valid=len(valid),
created=created,
dry_run=payload.dry_run,
errors=sorted(errors, key=lambda e: e.row),
)
@router.post("/edit", response_model=ClientOut)
def edit_client(payload: ClientEdit, db: Session = Depends(get_db)):
"""Edit a client — any subset of group, status, name, phone, alias."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None or payload.phone is not None or payload.alias is not None:
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
if meta is None:
meta = RadadminClient(mac_address=mac)
db.add(meta)
if payload.name is not None:
meta.name = payload.name
if payload.phone is not None:
meta.phone = payload.phone
if payload.alias is not None:
meta.alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
).scalar_one_or_none()
meta = db.execute(
select(RadadminClient).where(RadadminClient.mac_address == mac)
).scalar_one_or_none()
return ClientOut(
mac_address=mac, group=group, status=customer.status,
name=meta.name if meta else None,
phone=meta.phone if meta else None,
alias=meta.alias if meta else None,
)
@router.delete("/{mac_address}", status_code=204)
def delete_client(mac_address: str, db: Session = Depends(get_db)):
"""Remove a client from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _client_exists(db, mac):
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
db.execute(delete(RadadminClient).where(RadadminClient.mac_address == mac))
db.commit()
+75 -200
View File
@@ -1,221 +1,96 @@
"""Device management — a logical view spanning three tables.
"""Device management — the NAS/AP boxes seen in accounting.
A "device" is a client identified by its MAC address, which is used verbatim as
the RADIUS ``username``. One device touches three tables:
radcheck username = MAC, Cleartext-Password := MAC (auth)
radusergroup username = MAC, groupname = <group> (VLAN/group membership)
customers username = MAC, mac_address = MAC, status (billing/metadata)
This router hides that fan-out behind mac_address + group + status.
Unlike *clients* (customer MACs), a "device" here is a piece of network gear
identified by its **AP MAC** — the part before ':' in ``radacct.calledstationid``
(`<AP-MAC>:<SSID>`). Rows are grouped by that MAC, so one physical AP is a single
row even when it broadcasts several SSIDs (all SSIDs, and the NAS IP(s) it reports
from, are aggregated). An editable ``alias`` lives in the standalone
``radadmin_devices`` table, keyed by AP MAC — FreeRADIUS never reads it.
"""
from fastapi import APIRouter, Depends
from pydantic import ValidationError
from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..database import get_db
from ..errors import APIError
from ..models import Customer, RadCheck, RadGroupReply, RadUserGroup
from ..pagination import Page, PageParams
from ..schemas import (
DeviceCreate,
DeviceEdit,
DeviceImportError,
DeviceImportRequest,
DeviceImportResult,
DeviceOut,
)
from ..models import RadadminDevice, RadAcct
from ..schemas import DeviceAliasUpdate, DeviceOut
router = APIRouter(prefix="/device", tags=["device"])
def _group_exists(db: Session, group: str) -> bool:
stmt = select(RadGroupReply.id).where(RadGroupReply.groupname == group).limit(1)
return db.execute(stmt).first() is not None
def _norm_mac(mac: str) -> str:
"""Canonical AP MAC — uppercase, hyphen-separated (matches client MAC form)."""
return mac.strip().upper().replace(":", "-")
def _device_exists(db: Session, mac: str) -> bool:
stmt = select(Customer.id).where(Customer.username == mac).limit(1)
return db.execute(stmt).first() is not None
def _split_called(called: str | None) -> tuple[str | None, str | None]:
"""radacct.calledstationid is ``<AP-MAC>:<SSID>`` — split into (mac, ssid).
def _stage_device(db: Session, dev: DeviceCreate) -> None:
"""Add a device's three rows (radcheck, radusergroup, customers) to the session.
Does not commit — the caller controls the transaction boundary.
The MAC is normalized to uppercase-hyphen; the SSID is left verbatim.
"""
mac = dev.mac_address
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
db.add(RadUserGroup(username=mac, groupname=dev.group, priority=1))
db.add(Customer(
username=mac, mac_address=mac, status="paid",
name=dev.name, phone=dev.phone, device_alias=dev.alias,
))
if not called:
return None, None
if ":" in called:
mac, ssid = called.split(":", 1)
return (_norm_mac(mac) or None), (ssid or None)
return _norm_mac(called) or None, None
@router.get("/", response_model=Page[DeviceOut])
def list_devices(page: PageParams = Depends(), db: Session = Depends(get_db)):
"""List devices — MAC, group and status, joined from customers + radusergroup."""
base = select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
Customer.name, Customer.phone, Customer.device_alias,
).outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
total = db.execute(select(func.count()).select_from(Customer)).scalar_one()
rows = db.execute(base.order_by(Customer.id.desc()).limit(page.limit).offset(page.offset)).all()
items = [
DeviceOut(mac_address=mac, group=gn, status=st, name=nm, phone=ph, alias=al)
for mac, gn, st, nm, ph, al in rows
def _aliases(db: Session) -> dict[str, str | None]:
rows = db.execute(select(RadadminDevice.mac_address, RadadminDevice.alias)).all()
return {mac: alias for mac, alias in rows}
def _devices(db: Session) -> dict[str, dict]:
"""Aggregate radacct into {ap_mac: {ips, ssids}} over distinct station pairs."""
pairs = db.execute(
select(RadAcct.nasipaddress, RadAcct.calledstationid).distinct()
).all()
devices: dict[str, dict] = {}
for ip, called in pairs:
mac, ssid = _split_called(called)
if not mac:
continue
d = devices.setdefault(mac, {"ips": set(), "ssids": set()})
if ip:
d["ips"].add(ip)
if ssid:
d["ssids"].add(ssid)
return devices
def _device_out(ap_mac: str, agg: dict | None, alias: str | None) -> DeviceOut:
agg = agg or {"ips": set(), "ssids": set()}
return DeviceOut(
ap_mac=ap_mac,
nasipaddress=", ".join(sorted(agg["ips"])) or None,
ssids=sorted(agg["ssids"]),
alias=alias,
)
@router.get("/", response_model=list[DeviceOut])
def list_devices(db: Session = Depends(get_db)):
"""List NAS/AP devices — one row per AP MAC, SSIDs and NAS IPs aggregated."""
devices = _devices(db)
aliases = _aliases(db)
return [
_device_out(mac, devices[mac], aliases.get(mac))
for mac in sorted(devices)
]
return Page(total=int(total), limit=page.limit, offset=page.offset, items=items)
@router.get("/{mac_address}", response_model=DeviceOut)
def get_device(mac_address: str, db: Session = Depends(get_db)):
mac = mac_address.strip().upper().replace(":", "-")
stmt = (
select(
Customer.mac_address, RadUserGroup.groupname, Customer.status,
Customer.name, Customer.phone, Customer.device_alias,
)
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
.where(Customer.username == mac)
)
row = db.execute(stmt).first()
if row is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
return DeviceOut(
mac_address=row[0], group=row[1], status=row[2],
name=row[3], phone=row[4], alias=row[5],
)
@router.post("/add", response_model=DeviceOut, status_code=201)
def add_device(payload: DeviceCreate, db: Session = Depends(get_db)):
"""Register a device: create its radcheck, radusergroup and customer rows."""
mac = payload.mac_address
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found — create it first")
if _device_exists(db, mac):
raise APIError(status_code=409, detail=f"Device '{mac}' already exists")
_stage_device(db, payload)
db.commit()
return DeviceOut(
mac_address=mac, group=payload.group, status="paid",
name=payload.name, phone=payload.phone, alias=payload.alias,
)
def _format_validation_error(exc: ValidationError) -> str:
"""Turn a pydantic ValidationError into a short, human message."""
parts = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"])
parts.append(f"{loc}: {err['msg']}" if loc else err["msg"])
return "; ".join(parts)
@router.post("/import", response_model=DeviceImportResult)
def import_devices(payload: DeviceImportRequest, db: Session = Depends(get_db)):
"""Bulk-import devices from parsed CSV rows.
Every row is validated with the same rules as ``/device/add`` (MAC/phone
normalization, required fields, group-exists, duplicate MAC — both against the
DB and within the file). Errors are collected per row rather than failing the
batch. With ``dry_run`` nothing is written, so the UI can preview and confirm;
otherwise the valid rows are inserted best-effort (each in its own commit).
"""
errors: list[DeviceImportError] = []
valid: list[tuple[int, DeviceCreate]] = []
seen_macs: set[str] = set()
for idx, raw in enumerate(payload.devices, start=1):
try:
dev = DeviceCreate(**raw.model_dump())
except ValidationError as exc:
errors.append(DeviceImportError(row=idx, mac=raw.mac_address, detail=_format_validation_error(exc)))
continue
mac = dev.mac_address
if mac in seen_macs:
errors.append(DeviceImportError(row=idx, mac=mac, detail="Duplicate MAC within file"))
continue
if not _group_exists(db, dev.group):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Group '{dev.group}' not found"))
continue
if _device_exists(db, mac):
errors.append(DeviceImportError(row=idx, mac=mac, detail=f"Device '{mac}' already exists"))
continue
seen_macs.add(mac)
valid.append((idx, dev))
created = 0
if not payload.dry_run:
for idx, dev in valid:
_stage_device(db, dev)
try:
db.commit()
created += 1
except IntegrityError:
db.rollback()
errors.append(DeviceImportError(row=idx, mac=dev.mac_address, detail="Insert failed (integrity error)"))
return DeviceImportResult(
total=len(payload.devices),
valid=len(valid),
created=created,
dry_run=payload.dry_run,
errors=sorted(errors, key=lambda e: e.row),
)
@router.post("/edit", response_model=DeviceOut)
def edit_device(payload: DeviceEdit, db: Session = Depends(get_db)):
"""Edit a device — any subset of group, status, name, phone, alias."""
mac = payload.mac_address
customer = db.execute(select(Customer).where(Customer.username == mac)).scalar_one_or_none()
if customer is None:
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
if payload.group is not None:
if not _group_exists(db, payload.group):
raise APIError(status_code=400, detail=f"Group '{payload.group}' not found")
db.execute(
update(RadUserGroup).where(RadUserGroup.username == mac).values(groupname=payload.group)
)
if payload.status is not None:
customer.status = payload.status
if payload.name is not None:
customer.name = payload.name
if payload.phone is not None:
customer.phone = payload.phone
if payload.alias is not None:
customer.device_alias = payload.alias
db.commit()
group = db.execute(
select(RadUserGroup.groupname).where(RadUserGroup.username == mac).limit(1)
@router.put("/{ap_mac}", response_model=DeviceOut)
def set_alias(ap_mac: str, payload: DeviceAliasUpdate, db: Session = Depends(get_db)):
"""Set (or clear) the alias for an AP MAC — upserts into radadmin_devices."""
mac = _norm_mac(ap_mac)
row = db.execute(
select(RadadminDevice).where(RadadminDevice.mac_address == mac)
).scalar_one_or_none()
return DeviceOut(
mac_address=mac, group=group, status=customer.status,
name=customer.name, phone=customer.phone, alias=customer.device_alias,
)
@router.delete("/{mac_address}", status_code=204)
def delete_device(mac_address: str, db: Session = Depends(get_db)):
"""Remove a device from radcheck, radusergroup and customers."""
mac = mac_address.strip().upper().replace(":", "-")
if not _device_exists(db, mac):
raise APIError(status_code=404, detail=f"Device '{mac}' not found")
db.execute(delete(RadCheck).where(RadCheck.username == mac))
db.execute(delete(RadUserGroup).where(RadUserGroup.username == mac))
db.execute(delete(Customer).where(Customer.username == mac))
if row is None:
db.add(RadadminDevice(mac_address=mac, alias=payload.alias))
else:
row.alias = payload.alias
db.commit()
return _device_out(mac, _devices(db).get(mac), payload.alias)
+27 -19
View File
@@ -10,13 +10,12 @@ class ORMModel(BaseModel):
# ---------- customers ----------
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
# only the RADIUS-adjacent identity + billing status.
class CustomerBase(BaseModel):
username: str = Field(max_length=64)
mac_address: str = Field(max_length=17)
status: Literal["new", "paid", "unpaid"] = "new"
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerCreate(CustomerBase):
@@ -27,9 +26,6 @@ class CustomerUpdate(BaseModel):
username: str | None = Field(default=None, max_length=64)
mac_address: str | None = Field(default=None, max_length=17)
status: Literal["new", "paid", "unpaid"] | None = None
name: str | None = Field(default=None, max_length=128)
phone: str | None = Field(default=None, max_length=32)
device_alias: str | None = Field(default=None, max_length=64)
class CustomerOut(ORMModel, CustomerBase):
@@ -130,7 +126,7 @@ class VlanEdit(BaseModel):
alias: str = Field(min_length=1, max_length=64, description="New alias (groupname)")
# ---------- devices (logical view over radcheck + radusergroup + customers) ----------
# ---------- clients (logical view over radcheck + radusergroup + customers) ----------
_MAC_RE = r"^[0-9A-Fa-f]{2}([-:][0-9A-Fa-f]{2}){5}$"
@@ -157,16 +153,16 @@ def _normalize_phone(raw: str) -> str:
raise ValueError(_PHONE_ERROR)
class DeviceOut(BaseModel):
class ClientOut(BaseModel):
mac_address: str
group: str | None = None # radusergroup.groupname
status: str | None = None # customers.status
name: str | None = None # customers.name (human metadata)
phone: str | None = None # customers.phone (human metadata)
alias: str | None = None # customers.device_alias (human metadata)
alias: str | None = None # radadmin_clients.alias (human metadata)
class DeviceCreate(BaseModel):
class ClientCreate(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str = Field(min_length=1, max_length=64)
name: str = Field(min_length=1, max_length=128, description="Customer name (metadata, ignored by RADIUS)")
@@ -184,7 +180,7 @@ class DeviceCreate(BaseModel):
return _normalize_phone(v)
class DeviceEdit(BaseModel):
class ClientEdit(BaseModel):
mac_address: str = Field(pattern=_MAC_RE, max_length=17)
group: str | None = Field(default=None, max_length=64)
status: Literal["new", "paid", "unpaid"] | None = None
@@ -209,11 +205,11 @@ class DeviceEdit(BaseModel):
return self
# ---------- device CSV import ----------
class DeviceImportRow(BaseModel):
# ---------- client CSV import ----------
class ClientImportRow(BaseModel):
"""A single CSV row — lenient so one bad row never 422s the whole batch.
Each row is re-validated with ``DeviceCreate`` inside the router so failures
Each row is re-validated with ``ClientCreate`` inside the router so failures
are collected per-row instead of rejecting the entire request.
"""
@@ -224,23 +220,35 @@ class DeviceImportRow(BaseModel):
alias: str | None = None
class DeviceImportRequest(BaseModel):
devices: list[DeviceImportRow]
class ClientImportRequest(BaseModel):
devices: list[ClientImportRow]
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
class DeviceImportError(BaseModel):
class ClientImportError(BaseModel):
row: int # 1-based index within the submitted rows
mac: str | None = None
detail: str
class DeviceImportResult(BaseModel):
class ClientImportResult(BaseModel):
total: int # rows submitted
valid: int # rows that passed validation
created: int # rows actually inserted (0 on dry_run)
dry_run: bool
errors: list[DeviceImportError]
errors: list[ClientImportError]
# ---------- devices (NAS/AP boxes seen in radacct, aliased via radadmin_devices) ----------
class DeviceOut(BaseModel):
ap_mac: str # calledstationid before ':' — the device key
nasipaddress: str | None = None # NAS IP(s) this AP reports from (comma-joined)
ssids: list[str] = [] # every SSID seen for this AP MAC
alias: str | None = None # radadmin_devices.alias (human label)
class DeviceAliasUpdate(BaseModel):
alias: str | None = Field(default=None, max_length=64)
# ---------- radusergroup ----------
+19 -9
View File
@@ -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`).
+7 -2
View File
@@ -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>
@@ -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
View File
@@ -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) =>
+474
View File
@@ -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
View File
@@ -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>
)
}