Compare commits
29
Commits
9074e55a13
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b46c84a9
|
||
|
|
46f130150a
|
||
|
|
4a7de76155
|
||
|
|
2957347583
|
||
|
|
66073c7891
|
||
|
|
a7c5fe739a
|
||
|
|
45a35c0b01
|
||
|
|
f4c1872bfc
|
||
|
|
136bd5a199
|
||
|
|
251efd4d0c
|
||
|
|
4f2f953f34
|
||
|
|
1af12891a7
|
||
|
|
141d380abf
|
||
|
|
db140912f5
|
||
|
|
a6d4fa1f6d
|
||
|
|
6fe7c69adb
|
||
|
|
cd99049a92
|
||
|
|
d406e56856
|
||
|
|
2f66c04d1b
|
||
|
|
533faabab9
|
||
|
|
ab75dd33f1
|
||
|
|
29900e5625
|
||
|
|
010d98df1c | ||
|
|
79bc04aaa7
|
||
|
|
63381d9f2a
|
||
|
|
c450ac2903
|
||
|
|
428536fb1b
|
||
|
|
46f88ca04c
|
||
|
|
e90199803d
|
@@ -0,0 +1,6 @@
|
||||
VITE_API_TARGET=http://localhost:8080/
|
||||
|
||||
# Base path the client calls. Leave as /api to use the dev proxy (same-origin,
|
||||
# no CORS). For a production build served on a different origin than the API,
|
||||
# set this to the API's absolute URL, e.g. https://radapi.example.com
|
||||
VITE_API_BASE=/api
|
||||
@@ -0,0 +1,92 @@
|
||||
|
||||
## Production deployment
|
||||
|
||||
Both services build from the repo root (monorepo). The frontend container runs
|
||||
nginx, which serves the built SPA at `/` and reverse-proxies `/api` to the
|
||||
backend container. Only the frontend port needs to be published — the backend
|
||||
is reached internally over the compose network as `backend:8000`.
|
||||
|
||||
```
|
||||
host.com/ -> frontend (nginx static SPA)
|
||||
host.com/api/... -> backend (FastAPI, /api prefix stripped)
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```sh
|
||||
echo $VITE_API_TARGET=https://radiusadmin.example.com > .build/prod/.env
|
||||
docker compose -f .build/prod/compose.yml up -d --build
|
||||
```
|
||||
### Running from published images
|
||||
- compose.yml
|
||||
```yml
|
||||
services:
|
||||
database:
|
||||
image: mysql
|
||||
hostname: database
|
||||
environment:
|
||||
- MYSQL_RANDOM_ROOT_PASSWORD=yes
|
||||
- MYSQL_USER=radius
|
||||
- MYSQL_PASSWORD=radius
|
||||
- MYSQL_DATABASE=radius
|
||||
volumes:
|
||||
- ./mysql:/var/lib/mysql
|
||||
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server
|
||||
hostname: freeradius
|
||||
ports:
|
||||
- 1812:1812/udp
|
||||
- 1813:1813/udp
|
||||
# volumes:
|
||||
# - ./freeradius/sql:/etc/freeradius/mods-enabled/sql
|
||||
# - /etc/freeradius/3.0/:/etc/freeradius/
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
adminer:
|
||||
image: dockette/adminer:mysql
|
||||
hostname: adminer
|
||||
ports:
|
||||
- 8001:80
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
backend:
|
||||
image: git.shihaam.dev/sarlink/radadmin/backend
|
||||
hostname: backend
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- database
|
||||
|
||||
frontend:
|
||||
image: git.shihaam.dev/sarlink/radadmin/frontend
|
||||
hostname: frontend
|
||||
ports:
|
||||
- 8000:80
|
||||
depends_on:
|
||||
- backend
|
||||
```
|
||||
- Create `compose.yml` with the above yaml
|
||||
- Create .env and fill
|
||||
```
|
||||
# --- Database (FreeRADIUS) ---
|
||||
DB_HOST=database
|
||||
DB_PORT=3306
|
||||
DB_USER=radadmin
|
||||
DB_PASSWORD=changeme
|
||||
DB_NAME=radius
|
||||
|
||||
# --- API ---
|
||||
API_KEY=changeme
|
||||
```
|
||||
- Start the services `docker compose up -d` and wait few mins for db to initilize
|
||||
- copy db schema from freeradius and backend to mysql and import it
|
||||
```shell
|
||||
docker compose cp freeradius:/etc/freeradius/mods-config/sql/main/mysql/schema.sql .
|
||||
docker compose cp schema.sql database:/schema.sql
|
||||
docker compose exec database sh -c 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < /schema.sql'
|
||||
docker compose exec backend python migrate.py
|
||||
rm radadmin_schema.sql schema.sql
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY backend/ .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: .build/prod/frontend.Dockerfile
|
||||
args:
|
||||
VITE_API_TARGET: ${VITE_API_TARGET}
|
||||
hostname: frontend
|
||||
image: git.shihaam.dev/sarlink/radadmin/frontend
|
||||
backend:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: .build/prod/backend.Dockerfile
|
||||
hostname: backend
|
||||
image: git.shihaam.dev/sarlink/radadmin/backend
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:22.17.0-alpine AS frontendbuilder
|
||||
|
||||
ARG VITE_API_TARGET
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY frontend/ .
|
||||
RUN echo VITE_API_TARGET=$VITE_API_TARGET > .env \
|
||||
&& npm ci && npm run build
|
||||
|
||||
FROM nginx
|
||||
|
||||
COPY --from=frontendbuilder /var/www/html/dist /usr/share/nginx/html
|
||||
COPY .build/prod/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
name: build-and-push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker-compose
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shihaam.dev
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USER }}
|
||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
env:
|
||||
VITE_API_TARGET: ${{ vars.VITE_API_TARGET }}
|
||||
run: docker compose -f .build/prod/compose.yml build --push
|
||||
+14
-7
@@ -1,8 +1,15 @@
|
||||
venv/*
|
||||
.env
|
||||
tmp/
|
||||
.build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
@@ -1,303 +1,65 @@
|
||||
# FreeRADIUS REST API
|
||||
# radapi
|
||||
|
||||
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`).
|
||||
SARLINK FreeRADIUS management stack: a REST API over the
|
||||
FreeRADIUS schema and a web admin portal for it.
|
||||
|
||||
## Stack
|
||||
- **FastAPI** + **Uvicorn** (ASGI)
|
||||
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
|
||||
- **Pydantic v2** request/response validation
|
||||
- Auth via a shared secret in the **`X-API-Key`** header
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env # then edit credentials + API_KEY
|
||||
```
|
||||
.
|
||||
├── backend/ FastAPI REST API over the FreeRADIUS MySQL/MariaDB schema
|
||||
└── frontend/ React + Vite admin portal (radui)
|
||||
```
|
||||
|
||||
### `.env`
|
||||
## Components
|
||||
|
||||
| Var | Meaning |
|
||||
|-----------------|------------------------------------------------|
|
||||
| `DB_HOST` | MySQL host (`127.0.0.1` if API runs on the DB box) |
|
||||
| `DB_PORT` | MySQL port (default 3306) |
|
||||
| `DB_USER` / `DB_PASSWORD` | DB credentials |
|
||||
| `DB_NAME` | Database name (`radius`) |
|
||||
| `API_KEY` | Shared secret required in `X-API-Key` |
|
||||
| `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) |
|
||||
| `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps |
|
||||
| Dir | What | Stack |
|
||||
|-------------|----------------------------------------|--------------------------------------------|
|
||||
| `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 |
|
||||
| `frontend/` | Admin UI for clients, devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 |
|
||||
|
||||
> **Note:** MariaDB on the staging box binds to `127.0.0.1` only. To reach it from
|
||||
> another host either run the API on the RADIUS server (`DB_HOST=127.0.0.1`), open
|
||||
> an SSH tunnel (`ssh -N -L 13306:127.0.0.1:3306 root@HOST` then `DB_PORT=13306`),
|
||||
> or bind MariaDB to the LAN and grant remote access.
|
||||
Auth is **per-user**: sign in with a username and password and the backend issues
|
||||
a session token (sent as `Authorization: Bearer`). Admins can additionally mint
|
||||
revocable **`X-API-Key`** keys for scripts. On first deploy the API seeds a
|
||||
default **`admin` / `admin`** login that must change its password on first
|
||||
sign-in. Admins manage users, mint/revoke API keys, and view an activity log of
|
||||
every user's actions; regular users get full RADIUS access but can only change
|
||||
their own password.
|
||||
|
||||
## Run
|
||||
## Quick start
|
||||
|
||||
### With Docker (dev)
|
||||
|
||||
Runs both services with hot-reload — the frontend on Vite, the backend on
|
||||
`uvicorn --reload`, each with its source volume-mounted from the host.
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env # edit DB credentials
|
||||
docker compose up --build # frontend :5173, backend :8000
|
||||
```
|
||||
|
||||
The frontend proxies `/api/*` to the `backend` service over the compose
|
||||
network, so log in at http://localhost:5173 with **`admin` / `admin`** on first
|
||||
run, then change the password when prompted.
|
||||
|
||||
### Manual
|
||||
|
||||
**Backend** (see [`backend/README.md`](backend/README.md) for details):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env # edit DB credentials
|
||||
venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
- Interactive docs (Swagger): `http://HOST:8000/docs`
|
||||
- OpenAPI JSON: `http://HOST:8000/openapi.json`
|
||||
- Health check (no auth): `GET /health`
|
||||
|
||||
## Auth
|
||||
|
||||
Every resource endpoint requires the header:
|
||||
|
||||
```
|
||||
X-API-Key: <your API_KEY>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**Base URL (staging):** `http://10.0.1.235:8000`
|
||||
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
|
||||
**All bodies:** JSON with `Content-Type: application/json`.
|
||||
|
||||
### Response shapes
|
||||
|
||||
`GET /device/` — **paginated** envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 4,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"items": [
|
||||
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
|
||||
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /device/{mac}`, `POST /device/add`, `POST /device/edit` — a single device object:
|
||||
|
||||
```json
|
||||
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
|
||||
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
|
||||
```
|
||||
|
||||
`GET /vlan/` — **plain array** (not paginated):
|
||||
|
||||
```json
|
||||
[ { "alias": "residents", "vlanid": 51 }, { "alias": "staff", "vlanid": 55 } ]
|
||||
```
|
||||
|
||||
- `status` is always one of `"new"`, `"paid"`, `"unpaid"`.
|
||||
- `group`, `name`, `phone`, `alias` may be `null` on older rows.
|
||||
- `DELETE` returns **`204` with an empty body** (nothing to parse).
|
||||
|
||||
### Error shapes
|
||||
|
||||
Application errors (`400`, `401`, `404`, `409`) return a **string** detail:
|
||||
|
||||
```json
|
||||
{ "detail": "Device 'AA-BB-CC-DD-EE-11' already exists" }
|
||||
```
|
||||
|
||||
Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail:
|
||||
|
||||
```json
|
||||
{ "detail": [ { "type": "missing", "loc": ["body","phone"], "msg": "Field required" } ] }
|
||||
```
|
||||
|
||||
So in the UI: read `detail` directly when it's a string; when it's an array, join
|
||||
each entry's `msg` (and `loc`) for field-level messages.
|
||||
|
||||
### Typical portal flows
|
||||
|
||||
- **Provision a device:** `POST /device/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}`.
|
||||
- **Populate a VLAN dropdown:** `GET /vlan/` → map `alias` (value sent as `group`).
|
||||
|
||||
## Endpoints
|
||||
|
||||
All list endpoints return a paginated envelope and accept `?limit=&offset=` plus
|
||||
per-resource filters:
|
||||
|
||||
```json
|
||||
{ "total": 12, "limit": 50, "offset": 0, "items": [ ... ] }
|
||||
```
|
||||
|
||||
| Resource | Path | Methods | Filters |
|
||||
|-----------------|-------------------|--------------------------|----------------------------------|
|
||||
| Customers | `/customers` | GET, POST, PUT, DELETE | `username`, `mac_address`, `status` |
|
||||
| NAS clients | `/nas` | GET, POST, PUT, DELETE | `nasname`, `shortname` |
|
||||
| User check | `/radcheck` | GET, POST, PUT, DELETE | `username`, `attribute` |
|
||||
| User reply | `/radreply` | GET, POST, PUT, DELETE | `username`, `attribute` |
|
||||
| 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 | — |
|
||||
| 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` |
|
||||
| NAS reload | `/nasreload` | GET (read-only) | — |
|
||||
|
||||
`radacct`, `radpostauth`, and `nasreload` are **read-only** — FreeRADIUS owns writes.
|
||||
|
||||
### VLANs — `/vlan`
|
||||
|
||||
A VLAN is a logical entity (`alias` + `vlanid`) backed by three `radgroupreply`
|
||||
rows sharing one `groupname`: `Tunnel-Type=VLAN`, `Tunnel-Medium-Type=IEEE-802`,
|
||||
and `Tunnel-Private-Group-Id=<vlanid>`.
|
||||
|
||||
| Action | Request |
|
||||
|------------------|-----------------------------------------------------|
|
||||
| List VLANs | `GET /vlan/` → `[{"alias","vlanid"}, ...]` |
|
||||
| Add a VLAN | `POST /vlan/add` body `{"vlanid":55,"alias":"staff"}` (inserts the 3 rows) |
|
||||
| Rename alias | `POST /vlan/edit` body `{"vlanid":55,"alias":"employees"}` |
|
||||
| Delete a VLAN | `DELETE /vlan/{vlanid}` (removes all rows for that group) |
|
||||
|
||||
- List returns **one row per VLAN** — only the alias and VLAN ID, not the raw
|
||||
`Tunnel-*` attribute rows.
|
||||
- Duplicate **VLAN ID** or **alias** on add → `409`.
|
||||
- `vlanid` must be `1–4094`.
|
||||
- Renaming updates `radgroupreply.groupname` only; if you also map users to groups
|
||||
in `radusergroup`, update those separately.
|
||||
|
||||
### Devices — `/device`
|
||||
|
||||
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.
|
||||
|
||||
The `customers` table also carries **human-only metadata that RADIUS never reads**:
|
||||
`name`, `phone`, and `device_alias`. These are collected at device 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) |
|
||||
|
||||
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).
|
||||
|
||||
- **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`.
|
||||
- **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`.
|
||||
|
||||
## Examples
|
||||
|
||||
Replace host/key to match your deployment.
|
||||
**Frontend** (see [`frontend/README.md`](frontend/README.md) for details):
|
||||
|
||||
```bash
|
||||
# List customers
|
||||
curl http://10.0.1.235:8000/customers?limit=50 \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# List VLANs
|
||||
curl http://10.0.1.235:8000/vlan/ \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# Add a VLAN (alias "staff", VLAN ID 55)
|
||||
curl http://10.0.1.235:8000/vlan/add \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vlanid":55,"alias":"staff"}' -s | jq
|
||||
|
||||
# Rename a VLAN's alias
|
||||
curl http://10.0.1.235:8000/vlan/edit \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vlanid":55,"alias":"employees"}' -s | jq
|
||||
|
||||
# Delete VLAN 55
|
||||
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/ \
|
||||
-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 \
|
||||
-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 \
|
||||
-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 \
|
||||
-X DELETE \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# Create a customer
|
||||
curl http://10.0.1.235:8000/customers \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"AA-BB-CC-DD-EE-FF","mac_address":"AA-BB-CC-DD-EE-FF","status":"new"}' -s | jq
|
||||
|
||||
# Update a customer's status
|
||||
curl http://10.0.1.235:8000/customers/1 \
|
||||
-X PUT \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"paid"}' -s | jq
|
||||
|
||||
# Active accounting sessions (no stop time)
|
||||
curl http://10.0.1.235:8000/radacct?active=true \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# Health check (no key needed)
|
||||
curl http://10.0.1.235:8000/health -s | jq
|
||||
cd frontend
|
||||
npm install
|
||||
VITE_API_TARGET=http://127.0.0.1:8000 npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------------------------------------------|
|
||||
| 200 | OK |
|
||||
| 201 | Created |
|
||||
| 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 |
|
||||
| 409 | Duplicate / integrity conflict |
|
||||
| 422 | Request body failed validation |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
main.py FastAPI app, router wiring, auth + CORS
|
||||
config.py env-driven settings (pydantic-settings)
|
||||
database.py SQLAlchemy engine/session
|
||||
auth.py X-API-Key dependency
|
||||
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)
|
||||
schemas.py Pydantic request/response models
|
||||
routers/ one module per resource
|
||||
```
|
||||
Log in with **`admin` / `admin`** on first run (you'll be prompted to change the
|
||||
password). In dev, Vite proxies `/api/*` to the backend so requests stay
|
||||
same-origin.
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import secrets
|
||||
|
||||
from fastapi import Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
from .config import get_settings
|
||||
from .errors import APIError
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
def require_api_key(api_key: str | None = Security(api_key_header)) -> None:
|
||||
expected = get_settings().api_key
|
||||
if not api_key or not secrets.compare_digest(api_key, expected):
|
||||
raise APIError(status_code=401, detail="Invalid or missing API key")
|
||||
-77
@@ -1,77 +0,0 @@
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .auth import require_api_key
|
||||
from .config import get_settings
|
||||
from .database import engine
|
||||
from .errors import APIError, api_error_handler
|
||||
from .routers import (
|
||||
customers,
|
||||
device,
|
||||
nas,
|
||||
nasreload,
|
||||
radacct,
|
||||
radcheck,
|
||||
radgroupcheck,
|
||||
radgroupreply,
|
||||
radpostauth,
|
||||
radreply,
|
||||
radusergroup,
|
||||
vlan,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="FreeRADIUS REST API",
|
||||
description="RESTful CRUD over the FreeRADIUS SQL schema. "
|
||||
"All endpoints require the `X-API-Key` header.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_exception_handler(APIError, api_error_handler)
|
||||
|
||||
|
||||
# ---- unauthenticated meta endpoints ----
|
||||
@app.get("/", tags=["meta"])
|
||||
def root():
|
||||
return {"service": "freeradius-rest-api", "docs": "/docs", "health": "/health"}
|
||||
|
||||
|
||||
@app.get("/health", tags=["meta"])
|
||||
def health():
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
|
||||
|
||||
|
||||
# ---- authenticated resource routers ----
|
||||
protected = [Depends(require_api_key)]
|
||||
for module in (
|
||||
customers,
|
||||
nas,
|
||||
radcheck,
|
||||
radreply,
|
||||
radgroupcheck,
|
||||
radgroupreply,
|
||||
radusergroup,
|
||||
vlan,
|
||||
device,
|
||||
radacct,
|
||||
radpostauth,
|
||||
nasreload,
|
||||
):
|
||||
app.include_router(module.router, dependencies=protected)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""Device management — a logical view spanning three tables.
|
||||
|
||||
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.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import delete, func, select, update
|
||||
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, 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 _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
|
||||
|
||||
|
||||
@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
|
||||
]
|
||||
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")
|
||||
|
||||
db.add(RadCheck(username=mac, attribute="Cleartext-Password", op=":=", value=mac))
|
||||
db.add(RadUserGroup(username=mac, groupname=payload.group, priority=1))
|
||||
db.add(Customer(
|
||||
username=mac, mac_address=mac, status="paid",
|
||||
name=payload.name, phone=payload.phone, device_alias=payload.alias,
|
||||
))
|
||||
db.commit()
|
||||
return DeviceOut(
|
||||
mac_address=mac, group=payload.group, status="paid",
|
||||
name=payload.name, phone=payload.phone, alias=payload.alias,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
).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))
|
||||
db.commit()
|
||||
@@ -6,8 +6,9 @@ DB_PASSWORD=changeme
|
||||
DB_NAME=radius
|
||||
|
||||
# --- API ---
|
||||
# Shared secret required in the X-API-Key header on every request.
|
||||
API_KEY=change-this-to-a-long-random-string
|
||||
# No shared API secret anymore. Auth is per-user: the API seeds a default
|
||||
# admin/admin login on first run (must change password on first sign-in), and
|
||||
# admins mint revocable X-API-Key values from the web UI for programmatic use.
|
||||
|
||||
# Comma-separated CORS origins (use * only for trusted/dev networks)
|
||||
CORS_ORIGINS=*
|
||||
@@ -0,0 +1,8 @@
|
||||
venv/*
|
||||
.env
|
||||
tmp/
|
||||
.build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1,472 @@
|
||||
# FreeRADIUS REST API
|
||||
|
||||
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
|
||||
- **Pydantic v2** request/response validation
|
||||
- Per-user auth: username/password **login sessions** (`Authorization: Bearer`)
|
||||
plus admin-issued **`X-API-Key`** keys for programmatic access
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env # then edit DB credentials
|
||||
```
|
||||
|
||||
Import `radadmin_schema.sql` (after the FreeRADIUS `schema.sql`) so the auth
|
||||
tables exist. On first startup the API seeds a default **`admin` / `admin`**
|
||||
login and forces a password change on first sign-in.
|
||||
|
||||
### `.env`
|
||||
|
||||
| Var | Meaning |
|
||||
|-----------------|------------------------------------------------|
|
||||
| `DB_HOST` | MySQL host (`127.0.0.1` if API runs on the DB box) |
|
||||
| `DB_PORT` | MySQL port (default 3306) |
|
||||
| `DB_USER` / `DB_PASSWORD` | DB credentials |
|
||||
| `DB_NAME` | Database name (`radius`) |
|
||||
| `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) |
|
||||
| `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps |
|
||||
|
||||
> **Note:** MariaDB on the staging box binds to `127.0.0.1` only. To reach it from
|
||||
> another host either run the API on the RADIUS server (`DB_HOST=127.0.0.1`), open
|
||||
> an SSH tunnel (`ssh -N -L 13306:127.0.0.1:3306 root@HOST` then `DB_PORT=13306`),
|
||||
> or bind MariaDB to the LAN and grant remote access.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
- Interactive docs (Swagger): `http://HOST:8000/docs`
|
||||
- OpenAPI JSON: `http://HOST:8000/openapi.json`
|
||||
- Health check (no auth): `GET /health`
|
||||
|
||||
## Auth
|
||||
|
||||
Auth is **per-user**, not a shared secret. There are two ways to authenticate a
|
||||
request, both resolving to a user in `radadmin_admins`:
|
||||
|
||||
1. **Login session** (the web UI) — `POST /auth/login` with `{username, password}`
|
||||
returns a `token`. Send it on every request as:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
2. **API key** (scripts / integrations) — an **admin** mints a key in the UI
|
||||
(`POST /apikeys`). The raw key is shown once; send it as:
|
||||
|
||||
```
|
||||
X-API-Key: <key>
|
||||
```
|
||||
|
||||
Missing/invalid credentials → `401`. An action that needs admin rights when
|
||||
you're a regular user → `403`. `/`, `/health`, `/docs`, and `POST /auth/login`
|
||||
are open.
|
||||
|
||||
### Roles
|
||||
|
||||
- **admin** (`is_admin = 1`) — everything below, plus manage users, mint/revoke
|
||||
API keys, and read the activity log.
|
||||
- **user** (regular) — full access to the RADIUS resources (clients, VLANs,
|
||||
devices, …) and can change **their own** password, but cannot create/delete
|
||||
users, reset anyone else's password, manage API keys, or view the log.
|
||||
|
||||
### First deploy
|
||||
|
||||
The database seeds `admin` / `admin` on first run with a forced password change.
|
||||
Sign in, change the password when prompted, then create the real users.
|
||||
|
||||
### Auth & admin endpoints
|
||||
|
||||
| Path | Method | Who | Purpose |
|
||||
|-------------------------------|--------|-------|-------------------------------------------|
|
||||
| `/auth/login` | POST | open | `{username,password}` → `{token, username, is_admin, must_change_password}` |
|
||||
| `/auth/logout` | POST | any | Invalidate the current session token |
|
||||
| `/auth/me` | GET | any | Current user `{id, username, is_admin, must_change_password}` |
|
||||
| `/auth/change-password` | POST | any | Change **your own** password `{current_password, new_password}` |
|
||||
| `/users` | GET | admin | List admin-portal users |
|
||||
| `/users` | POST | admin | Create a user `{username, password, is_admin, force_password_change}` (`force_password_change` defaults to `true`) |
|
||||
| `/users/{id}` | DELETE | admin | Delete a user (not self, not last admin) |
|
||||
| `/users/{id}/reset-password` | POST | admin | Reset another user's password `{new_password}` |
|
||||
| `/apikeys` | GET | admin | List API keys (hashes never returned) |
|
||||
| `/apikeys` | POST | admin | Create a key `{name}` → response includes raw `key` once |
|
||||
| `/apikeys/{id}` | DELETE | admin | Revoke a key |
|
||||
| `/logs` | GET | admin | Paginated activity log (`?username=&action=`) |
|
||||
|
||||
### Activity log
|
||||
|
||||
Every **state-changing** request (`POST`/`PUT`/`PATCH`/`DELETE`) and every auth
|
||||
event (login, logout, password change, user & key management) is recorded to
|
||||
`radadmin_logs` with the acting username, action, a detail string, the client IP,
|
||||
and a timestamp. Admins read it at `GET /logs`.
|
||||
|
||||
## UI integration guide
|
||||
|
||||
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:** an auth header — `Authorization: Bearer <token>` from
|
||||
`POST /auth/login`, or `X-API-Key: <key>` for an admin-issued key (except
|
||||
`/health` and `/auth/login`).
|
||||
**All bodies:** JSON with `Content-Type: application/json`.
|
||||
|
||||
### Response shapes
|
||||
|
||||
`GET /client/` — **paginated** envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 4,
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"items": [
|
||||
{ "mac_address": "AA-BB-CC-DD-EE-11", "group": "residents", "status": "paid",
|
||||
"name": "Sara Ib", "phone": "9998887", "alias": "Living Room TV" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
[ { "alias": "residents", "vlanid": 51 }, { "alias": "staff", "vlanid": 55 } ]
|
||||
```
|
||||
|
||||
- `status` is always one of `"new"`, `"paid"`, `"unpaid"`.
|
||||
- `group`, `name`, `phone`, `alias` may be `null` on older rows.
|
||||
- `DELETE` returns **`204` with an empty body** (nothing to parse).
|
||||
|
||||
### Error shapes
|
||||
|
||||
Application errors (`400`, `401`, `404`, `409`) return a **string** detail:
|
||||
|
||||
```json
|
||||
{ "detail": "Client 'AA-BB-CC-DD-EE-11' already exists" }
|
||||
```
|
||||
|
||||
Validation errors (`422`, bad/missing fields) return a FastAPI **array** detail:
|
||||
|
||||
```json
|
||||
{ "detail": [ { "type": "missing", "loc": ["body","phone"], "msg": "Field required" } ] }
|
||||
```
|
||||
|
||||
So in the UI: read `detail` directly when it's a string; when it's an array, join
|
||||
each entry's `msg` (and `loc`) for field-level messages.
|
||||
|
||||
### Typical portal flows
|
||||
|
||||
- **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 /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
|
||||
|
||||
All list endpoints return a paginated envelope and accept `?limit=&offset=` plus
|
||||
per-resource filters:
|
||||
|
||||
```json
|
||||
{ "total": 12, "limit": 50, "offset": 0, "items": [ ... ] }
|
||||
```
|
||||
|
||||
| Resource | Path | Methods | Filters |
|
||||
|-----------------|-------------------|--------------------------|----------------------------------|
|
||||
| Customers | `/customers` | GET, POST, PUT, DELETE | `username`, `mac_address`, `status` |
|
||||
| NAS clients | `/nas` | GET, POST, PUT, DELETE | `nasname`, `shortname` |
|
||||
| User check | `/radcheck` | GET, POST, PUT, DELETE | `username`, `attribute` |
|
||||
| User reply | `/radreply` | GET, POST, PUT, DELETE | `username`, `attribute` |
|
||||
| Group check | `/radgroupcheck` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
|
||||
| Group reply | `/radgroupreply` | GET, POST, PUT, DELETE | `groupname`, `attribute` |
|
||||
| **VLANs** | `/vlan` | see below | — |
|
||||
| **Clients** | `/client` | see below | `search`, `status` |
|
||||
| **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` |
|
||||
| NAS reload | `/nasreload` | GET (read-only) | — |
|
||||
|
||||
`radacct`, `radpostauth`, and `nasreload` are **read-only** — FreeRADIUS owns writes.
|
||||
|
||||
### VLANs — `/vlan`
|
||||
|
||||
A VLAN is a logical entity (`alias` + `vlanid`) backed by three `radgroupreply`
|
||||
rows sharing one `groupname`: `Tunnel-Type=VLAN`, `Tunnel-Medium-Type=IEEE-802`,
|
||||
and `Tunnel-Private-Group-Id=<vlanid>`.
|
||||
|
||||
| Action | Request |
|
||||
|------------------|-----------------------------------------------------|
|
||||
| List VLANs | `GET /vlan/` → `[{"alias","vlanid"}, ...]` |
|
||||
| Add a VLAN | `POST /vlan/add` body `{"vlanid":55,"alias":"staff"}` (inserts the 3 rows) |
|
||||
| Rename alias | `POST /vlan/edit` body `{"vlanid":55,"alias":"employees"}` |
|
||||
| Delete a VLAN | `DELETE /vlan/{vlanid}` (removes all rows for that group) |
|
||||
|
||||
- List returns **one row per VLAN** — only the alias and VLAN ID, not the raw
|
||||
`Tunnel-*` attribute rows.
|
||||
- Duplicate **VLAN ID** or **alias** on add → `409`.
|
||||
- `vlanid` must be `1–4094`.
|
||||
- Renaming updates `radgroupreply.groupname` only; if you also map users to groups
|
||||
in `radusergroup`, update those separately.
|
||||
|
||||
### Clients — `/client`
|
||||
|
||||
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 **`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 clients | `GET /client/` → `{total,limit,offset,items:[{mac_address,group,status,name,phone,alias}]}` |
|
||||
| Search / filter | `GET /client/?search=<term>&status=<new\|paid\|unpaid>` |
|
||||
| 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 `radadmin_clients` (`name`, `phone`, `alias`) and returned on every client
|
||||
response.
|
||||
|
||||
- **Add** inserts a `radcheck` password (`Cleartext-Password := MAC`), a
|
||||
`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,
|
||||
`status` in `customers.status`, and `name`/`phone`/`alias` in `radadmin_clients`.
|
||||
- Adding a client whose MAC already exists → `409`.
|
||||
- **List** accepts two optional filters (both applied before pagination, so
|
||||
`total` reflects the filtered set): `search` is a case-insensitive substring
|
||||
matched across `mac_address`, `name`, `phone`, and `alias`; `status` is an exact
|
||||
match on `new` / `paid` / `unpaid`. Combine them freely, e.g.
|
||||
`GET /client/?search=ali&status=unpaid`.
|
||||
- **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
|
||||
|
||||
Replace host to match your deployment. The `X-API-Key` header below stands for an
|
||||
**admin-issued key** — or swap any of these for `-H "Authorization: Bearer $TOKEN"`
|
||||
using a token from `/auth/login`.
|
||||
|
||||
```bash
|
||||
# Log in (default seeded creds on first deploy: admin / admin)
|
||||
curl http://10.0.1.235:8000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"admin"}' -s | jq
|
||||
|
||||
# Change your own password (required after the first login)
|
||||
curl http://10.0.1.235:8000/auth/change-password \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"current_password":"admin","new_password":"a-better-password"}' -s | jq
|
||||
|
||||
# Create a user (admin only)
|
||||
curl http://10.0.1.235:8000/users \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"ops","password":"changeme","is_admin":false}' -s | jq
|
||||
|
||||
# Mint an API key (admin only) — the raw "key" is shown once in the response
|
||||
curl http://10.0.1.235:8000/apikeys \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"billing-cron"}' -s | jq
|
||||
|
||||
# Read the activity log (admin only)
|
||||
curl http://10.0.1.235:8000/logs?limit=50 \
|
||||
-H "Authorization: Bearer $TOKEN" -s | jq
|
||||
|
||||
# List customers (using an admin-issued API key)
|
||||
curl http://10.0.1.235:8000/customers?limit=50 \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# List VLANs
|
||||
curl http://10.0.1.235:8000/vlan/ \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# Add a VLAN (alias "staff", VLAN ID 55)
|
||||
curl http://10.0.1.235:8000/vlan/add \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vlanid":55,"alias":"staff"}' -s | jq
|
||||
|
||||
# Rename a VLAN's alias
|
||||
curl http://10.0.1.235:8000/vlan/edit \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vlanid":55,"alias":"employees"}' -s | jq
|
||||
|
||||
# Delete VLAN 55
|
||||
curl http://10.0.1.235:8000/vlan/55 \
|
||||
-X DELETE \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# List clients
|
||||
curl http://10.0.1.235:8000/client/ \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# 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 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
|
||||
|
||||
# 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" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"AA-BB-CC-DD-EE-FF","mac_address":"AA-BB-CC-DD-EE-FF","status":"new"}' -s | jq
|
||||
|
||||
# Update a customer's status
|
||||
curl http://10.0.1.235:8000/customers/1 \
|
||||
-X PUT \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"paid"}' -s | jq
|
||||
|
||||
# Active accounting sessions (no stop time)
|
||||
curl http://10.0.1.235:8000/radacct?active=true \
|
||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||
|
||||
# Health check (no key needed)
|
||||
curl http://10.0.1.235:8000/health -s | jq
|
||||
```
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------------------------------------------|
|
||||
| 200 | OK |
|
||||
| 201 | Created |
|
||||
| 204 | Deleted (no content) |
|
||||
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
|
||||
| 401 | Missing/invalid credentials (Bearer token or API key) |
|
||||
| 403 | Authenticated but not an admin for an admin-only action |
|
||||
| 404 | Row / client / device / VLAN not found |
|
||||
| 409 | Duplicate / integrity conflict |
|
||||
| 422 | Request body failed validation |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/
|
||||
main.py FastAPI app, router wiring, activity-log middleware, CORS
|
||||
config.py env-driven settings (pydantic-settings)
|
||||
database.py SQLAlchemy engine/session
|
||||
auth.py password hashing, sessions, API keys, auth dependencies
|
||||
bootstrap.py first-run seeding of the default admin/admin account
|
||||
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 (incl. radadmin_admins/sessions/api_keys/logs)
|
||||
schemas.py Pydantic request/response models
|
||||
routers/ one module per resource
|
||||
auth.py login / logout / me / change-password
|
||||
users.py admin user management (create/delete/reset-password)
|
||||
apikeys.py admin API-key management (create/list/revoke)
|
||||
logs.py admin activity-log view
|
||||
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, ...
|
||||
```
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Password hashing, opaque login sessions, API keys, and the auth
|
||||
dependencies used to protect the API.
|
||||
|
||||
There is no shared secret in the environment anymore. Requests authenticate as
|
||||
a specific admin-portal user, either with a login session token
|
||||
(``Authorization: Bearer <token>``, issued by ``POST /auth/login``) or with a
|
||||
programmatic API key (``X-API-Key: <key>``, created by an admin). Both resolve
|
||||
to a row in ``radadmin_admins``; an API key acts as the user who created it.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import Depends, Request, Security
|
||||
from fastapi.security import APIKeyHeader, HTTPBearer
|
||||
from fastapi.security.http import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .database import get_db
|
||||
from .errors import APIError
|
||||
from .models import Admin, ApiKey, LogEntry, Session
|
||||
|
||||
# Sessions live this long before a fresh login is required.
|
||||
SESSION_TTL = timedelta(days=7)
|
||||
|
||||
_PBKDF2_ITERATIONS = 240_000
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password + key hashing (PBKDF2-HMAC-SHA256, stdlib only — no native deps)
|
||||
# ---------------------------------------------------------------------------
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||
salt = secrets.token_bytes(16)
|
||||
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _PBKDF2_ITERATIONS)
|
||||
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
algo, iterations, salt_hex, hash_hex = stored.split("$")
|
||||
if algo != "pbkdf2_sha256":
|
||||
return False
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return secrets.compare_digest(dk.hex(), hash_hex)
|
||||
|
||||
|
||||
# API keys reuse the same PBKDF2 scheme; the raw key is only shown once.
|
||||
hash_api_key = hash_password
|
||||
verify_api_key = verify_password
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""A 64-char hex session token (fits ``radadmin_sessions.token CHAR(64)``)."""
|
||||
return secrets.token_hex(32)
|
||||
|
||||
|
||||
def generate_api_key() -> str:
|
||||
"""A user-facing API key, e.g. ``rak_<40 hex chars>``."""
|
||||
return "rak_" + secrets.token_hex(20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
def create_session(db: DbSession, admin: Admin) -> str:
|
||||
token = generate_token()
|
||||
db.add(Session(token=token, admin_id=admin.id, expires_at=datetime.utcnow() + SESSION_TTL))
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
def _admin_from_bearer(db: DbSession, token: str) -> Admin | None:
|
||||
session = db.get(Session, token)
|
||||
if session is None:
|
||||
return None
|
||||
if session.expires_at < datetime.utcnow():
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
return None
|
||||
return db.get(Admin, session.admin_id)
|
||||
|
||||
|
||||
# A synthetic principal for API keys whose creator no longer exists. It can act
|
||||
# on resources but is not a real DB user, so it can't manage users/keys/logs.
|
||||
_SYSTEM_ADMIN = Admin(id=0, username="api-key", is_admin=False, must_change_password=False)
|
||||
|
||||
|
||||
def _admin_from_api_key(db: DbSession, raw_key: str) -> Admin | None:
|
||||
prefix = raw_key[:8]
|
||||
rows = (
|
||||
db.execute(select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.revoked.is_(False)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if verify_api_key(raw_key, row.key_hash):
|
||||
row.last_used_at = datetime.utcnow()
|
||||
db.commit()
|
||||
creator = db.execute(
|
||||
select(Admin).where(Admin.username == row.created_by)
|
||||
).scalar_one_or_none()
|
||||
return creator or _SYSTEM_ADMIN
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_current_admin(
|
||||
request: Request,
|
||||
bearer: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
|
||||
api_key: str | None = Security(api_key_header),
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> Admin:
|
||||
"""Resolve the caller from a Bearer session token or an X-API-Key.
|
||||
|
||||
Stashes the resolved username on ``request.state`` so the activity-log
|
||||
middleware can attribute the request without re-parsing credentials.
|
||||
"""
|
||||
admin: Admin | None = None
|
||||
if bearer and bearer.credentials:
|
||||
admin = _admin_from_bearer(db, bearer.credentials)
|
||||
if admin is None and api_key:
|
||||
admin = _admin_from_api_key(db, api_key)
|
||||
if admin is None:
|
||||
raise APIError(status_code=401, detail="Invalid or missing credentials")
|
||||
request.state.username = admin.username
|
||||
return admin
|
||||
|
||||
|
||||
def require_admin(admin: Admin = Depends(get_current_admin)) -> Admin:
|
||||
"""Guard for endpoints only full admins may call."""
|
||||
if not admin.is_admin:
|
||||
raise APIError(status_code=403, detail="Administrator privileges required")
|
||||
return admin
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Activity log helper
|
||||
# ---------------------------------------------------------------------------
|
||||
def record_log(
|
||||
db: DbSession,
|
||||
*,
|
||||
username: str | None,
|
||||
action: str,
|
||||
detail: str | None = None,
|
||||
request: Request | None = None,
|
||||
) -> None:
|
||||
"""Insert one activity-log row. Never raises into the caller."""
|
||||
ip = None
|
||||
if request is not None and request.client is not None:
|
||||
ip = request.client.host
|
||||
try:
|
||||
db.add(LogEntry(username=username, action=action, detail=detail, ip_address=ip))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""First-run seeding. Creates the default ``admin`` / ``admin`` account when
|
||||
``radadmin_admins`` is empty, forcing a password change on first login.
|
||||
Idempotent: once any admin exists this does nothing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from . import auth
|
||||
from .database import SessionLocal
|
||||
from .models import Admin
|
||||
|
||||
log = logging.getLogger("uvicorn.error")
|
||||
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin"
|
||||
|
||||
|
||||
def seed_default_admin() -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = db.execute(select(func.count()).select_from(Admin)).scalar_one()
|
||||
if count:
|
||||
return
|
||||
db.add(
|
||||
Admin(
|
||||
username=DEFAULT_USERNAME,
|
||||
password_hash=auth.hash_password(DEFAULT_PASSWORD),
|
||||
is_admin=True,
|
||||
must_change_password=True,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
log.warning(
|
||||
"Seeded default admin account '%s' / '%s' — change this password on first login.",
|
||||
DEFAULT_USERNAME,
|
||||
DEFAULT_PASSWORD,
|
||||
)
|
||||
except Exception as exc: # never block startup on a seeding hiccup
|
||||
db.rollback()
|
||||
log.error("Could not seed default admin: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -12,7 +12,6 @@ class Settings(BaseSettings):
|
||||
db_password: str = ""
|
||||
db_name: str = "radius"
|
||||
|
||||
api_key: str = "change-me"
|
||||
cors_origins: str = "*"
|
||||
default_limit: int = 50
|
||||
max_limit: int = 500
|
||||
@@ -0,0 +1,131 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .auth import get_current_admin, record_log
|
||||
from .bootstrap import seed_default_admin
|
||||
from .config import get_settings
|
||||
from .database import SessionLocal, engine
|
||||
from .errors import APIError, api_error_handler
|
||||
from .routers import (
|
||||
apikeys,
|
||||
auth,
|
||||
client,
|
||||
customers,
|
||||
device,
|
||||
logs,
|
||||
nas,
|
||||
nasreload,
|
||||
radacct,
|
||||
radcheck,
|
||||
radgroupcheck,
|
||||
radgroupreply,
|
||||
radpostauth,
|
||||
radreply,
|
||||
radusergroup,
|
||||
users,
|
||||
vlan,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Seed the default admin/admin account on first run.
|
||||
seed_default_admin()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="FreeRADIUS REST API",
|
||||
description="RESTful CRUD over the FreeRADIUS SQL schema. Authenticate with a "
|
||||
"login session (`Authorization: Bearer`) or an admin-issued `X-API-Key`.",
|
||||
version="2.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.add_exception_handler(APIError, api_error_handler)
|
||||
|
||||
|
||||
# Management routers self-log with rich detail; skip them here to avoid dupes.
|
||||
_SELF_LOGGED_PREFIXES = ("/auth", "/users", "/apikeys")
|
||||
_LOGGED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def activity_log_middleware(request: Request, call_next):
|
||||
"""Record every successful state-changing request to radadmin_logs, attributed
|
||||
to the user resolved by the auth dependency (stashed on request.state)."""
|
||||
response = await call_next(request)
|
||||
if request.method in _LOGGED_METHODS and response.status_code < 400:
|
||||
path = request.url.path
|
||||
if not path.startswith(_SELF_LOGGED_PREFIXES):
|
||||
username = getattr(request.state, "username", None)
|
||||
if username:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
record_log(
|
||||
db,
|
||||
username=username,
|
||||
action=request.method.lower(),
|
||||
detail=f"{request.method} {path}",
|
||||
request=request,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
return response
|
||||
|
||||
|
||||
# ---- unauthenticated meta endpoints ----
|
||||
@app.get("/", tags=["meta"])
|
||||
def root():
|
||||
return {"service": "freeradius-rest-api", "docs": "/docs", "health": "/health"}
|
||||
|
||||
|
||||
@app.get("/health", tags=["meta"])
|
||||
def health():
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
db_ok = False
|
||||
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
|
||||
|
||||
|
||||
# ---- auth + management routers (guard themselves per-endpoint) ----
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(apikeys.router)
|
||||
app.include_router(logs.router)
|
||||
|
||||
|
||||
# ---- authenticated resource routers (any logged-in user or API key) ----
|
||||
protected = [Depends(get_current_admin)]
|
||||
for module in (
|
||||
customers,
|
||||
nas,
|
||||
radcheck,
|
||||
radreply,
|
||||
radgroupcheck,
|
||||
radgroupreply,
|
||||
radusergroup,
|
||||
vlan,
|
||||
client,
|
||||
device,
|
||||
radacct,
|
||||
radpostauth,
|
||||
nasreload,
|
||||
):
|
||||
app.include_router(module.router, dependencies=protected)
|
||||
@@ -1,11 +1,71 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, func
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
class Admin(Base):
|
||||
"""An admin-portal login. `is_admin` grants user/API-key/log management."""
|
||||
|
||||
__tablename__ = "radadmin_admins"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
|
||||
|
||||
class Session(Base):
|
||||
"""An opaque login token handed to the browser (Authorization: Bearer)."""
|
||||
|
||||
__tablename__ = "radadmin_sessions"
|
||||
|
||||
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
admin_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""A programmatic key (X-API-Key). Only the hash is stored."""
|
||||
|
||||
__tablename__ = "radadmin_api_keys"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
key_prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class LogEntry(Base):
|
||||
"""One activity-log row per state-changing action or auth event."""
|
||||
|
||||
__tablename__ = "radadmin_logs"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
detail: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
__tablename__ = "customers"
|
||||
|
||||
@@ -13,9 +73,45 @@ 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)
|
||||
# Not DB-unique on its own — uniqueness is enforced only among live rows by the
|
||||
# generated `active_mac` column (see radadmin_schema.sql). Soft-deleted rows
|
||||
# (deleted_at set) may share a mac_address with the live row and each other.
|
||||
mac_address: Mapped[str] = mapped_column(String(17), nullable=False)
|
||||
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)
|
||||
# Mirror of the client's group (radusergroup) and billing status (customers):
|
||||
# set on add, synced on edit, and kept frozen on the row after soft-delete so
|
||||
# the deleted client's group/status survive their hard-deleted source rows.
|
||||
groupname: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
|
||||
status: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, nullable=True, server_default=func.current_timestamp()
|
||||
)
|
||||
# Soft delete: NULL = live, a timestamp = deleted. Deleted rows are never
|
||||
# returned by the API and are kept only for restore/reference.
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
|
||||
|
||||
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()
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Programmatic API keys. Only admins may create, list, or revoke keys.
|
||||
|
||||
A key authenticates via the ``X-API-Key`` header and acts as the admin who
|
||||
created it. The raw key is returned exactly once, at creation; afterwards only
|
||||
its prefix and hash are stored.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, ApiKey
|
||||
from ..schemas import ApiKeyCreate, ApiKeyCreated, ApiKeyOut
|
||||
|
||||
router = APIRouter(prefix="/apikeys", tags=["apikeys"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApiKeyOut])
|
||||
def list_api_keys(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||
return db.execute(select(ApiKey).order_by(ApiKey.id.desc())).scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=ApiKeyCreated, status_code=201)
|
||||
def create_api_key(
|
||||
payload: ApiKeyCreate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
raw = auth.generate_api_key()
|
||||
key = ApiKey(
|
||||
name=payload.name,
|
||||
key_prefix=raw[:8],
|
||||
key_hash=auth.hash_api_key(raw),
|
||||
created_by=admin.username,
|
||||
)
|
||||
db.add(key)
|
||||
db.commit()
|
||||
db.refresh(key)
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="create_api_key",
|
||||
detail=f"Created API key '{key.name}' ({key.key_prefix}…)",
|
||||
request=request,
|
||||
)
|
||||
return ApiKeyCreated(**ApiKeyOut.model_validate(key).model_dump(), key=raw)
|
||||
|
||||
|
||||
@router.delete("/{key_id}", status_code=204)
|
||||
def revoke_api_key(
|
||||
key_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
key = db.get(ApiKey, key_id)
|
||||
if key is None:
|
||||
raise APIError(status_code=404, detail=f"API key '{key_id}' not found")
|
||||
key.revoked = True
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="revoke_api_key",
|
||||
detail=f"Revoked API key '{key.name}' ({key.key_prefix}…)",
|
||||
request=request,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Login, logout, current-user and self-service password change.
|
||||
|
||||
``POST /auth/login`` is the only unauthenticated endpoint here; the rest resolve
|
||||
the caller from their Bearer session token.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Security
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import bearer_scheme, get_current_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, Session
|
||||
from ..schemas import (
|
||||
ChangePasswordRequest,
|
||||
CurrentUser,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, db: DbSession = Depends(get_db)):
|
||||
admin = db.execute(
|
||||
select(Admin).where(Admin.username == payload.username)
|
||||
).scalar_one_or_none()
|
||||
if admin is None or not auth.verify_password(payload.password, admin.password_hash):
|
||||
# Generic message so we don't leak which usernames exist.
|
||||
raise APIError(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = auth.create_session(db, admin)
|
||||
auth.record_log(
|
||||
db, username=admin.username, action="login", detail="Signed in", request=request
|
||||
)
|
||||
return LoginResponse(
|
||||
token=token,
|
||||
username=admin.username,
|
||||
is_admin=admin.is_admin,
|
||||
must_change_password=admin.must_change_password,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
def logout(
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
creds=Security(bearer_scheme),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
if creds and creds.credentials:
|
||||
session = db.get(Session, creds.credentials)
|
||||
if session is not None:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
auth.record_log(db, username=admin.username, action="logout", detail="Signed out", request=request)
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUser)
|
||||
def me(admin: Admin = Depends(get_current_admin)):
|
||||
return admin
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=204)
|
||||
def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
"""Change your OWN password. Any authenticated user may do this.
|
||||
|
||||
When the account is flagged ``must_change_password`` (a forced first-login or
|
||||
post-reset change) the current password is not required — the user just
|
||||
authenticated with it. A normal self-service change still verifies it.
|
||||
"""
|
||||
if not admin.must_change_password:
|
||||
if not payload.current_password or not auth.verify_password(
|
||||
payload.current_password, admin.password_hash
|
||||
):
|
||||
raise APIError(status_code=400, detail="Current password is incorrect")
|
||||
admin.password_hash = auth.hash_password(payload.new_password)
|
||||
admin.must_change_password = False
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="change_password",
|
||||
detail="Changed own password",
|
||||
request=request,
|
||||
)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""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 and_, delete, func, or_, 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
|
||||
status = "paid"
|
||||
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=status))
|
||||
# radadmin_clients mirrors the client's group/status too, so the row is a full
|
||||
# record that survives (frozen) after the RADIUS/billing rows are hard-deleted.
|
||||
db.add(RadadminClient(
|
||||
mac_address=mac, name=cli.name, phone=cli.phone, alias=cli.alias,
|
||||
groupname=cli.group, status=status,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/", response_model=Page[ClientOut])
|
||||
def list_clients(
|
||||
page: PageParams = Depends(),
|
||||
search: str | None = None,
|
||||
status: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List clients — MAC, group and status, joined from customers + radusergroup.
|
||||
|
||||
Optional filters:
|
||||
- ``search`` case-insensitive substring match across MAC, name, phone, alias.
|
||||
- ``status`` exact match on billing status (new/paid/unpaid).
|
||||
"""
|
||||
base = (
|
||||
select(
|
||||
Customer.mac_address, RadUserGroup.groupname, Customer.status,
|
||||
RadadminClient.name, RadadminClient.phone, RadadminClient.alias,
|
||||
)
|
||||
.outerjoin(RadUserGroup, RadUserGroup.username == Customer.username)
|
||||
.outerjoin(
|
||||
RadadminClient,
|
||||
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
|
||||
)
|
||||
)
|
||||
# Count over the same joins so filtered totals drive pagination correctly.
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Customer)
|
||||
.outerjoin(
|
||||
RadadminClient,
|
||||
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
cond = or_(
|
||||
Customer.mac_address.ilike(term),
|
||||
RadadminClient.name.ilike(term),
|
||||
RadadminClient.phone.ilike(term),
|
||||
RadadminClient.alias.ilike(term),
|
||||
)
|
||||
base = base.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
if status:
|
||||
base = base.where(Customer.status == status)
|
||||
count_stmt = count_stmt.where(Customer.status == status)
|
||||
|
||||
total = db.execute(count_stmt).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,
|
||||
and_(RadadminClient.mac_address == Customer.username, RadadminClient.deleted_at.is_(None)),
|
||||
)
|
||||
.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")
|
||||
|
||||
# The live metadata row mirrors group/status/name/phone/alias; created at add,
|
||||
# but create it here too in case a legacy client predates the mirror.
|
||||
meta = db.execute(
|
||||
select(RadadminClient).where(
|
||||
RadadminClient.mac_address == mac, RadadminClient.deleted_at.is_(None)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if meta is None:
|
||||
meta = RadadminClient(mac_address=mac, groupname=None, status=customer.status)
|
||||
db.add(meta)
|
||||
|
||||
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)
|
||||
)
|
||||
meta.groupname = payload.group
|
||||
|
||||
if payload.status is not None:
|
||||
customer.status = payload.status
|
||||
meta.status = payload.status
|
||||
|
||||
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, RadadminClient.deleted_at.is_(None)
|
||||
)
|
||||
).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)):
|
||||
"""Delete a client: hard-remove its radcheck, radusergroup and customers rows,
|
||||
but only *soft* delete its radadmin_clients metadata (name/phone/alias) by
|
||||
stamping deleted_at, so it can be restored/referenced later. Soft-deleted rows
|
||||
are never returned by the API, and the client's MAC may be freely re-added."""
|
||||
mac = mac_address.strip().upper().replace(":", "-")
|
||||
if not _client_exists(db, mac):
|
||||
raise APIError(status_code=404, detail=f"Client '{mac}' not found")
|
||||
|
||||
# radadmin_clients already mirrors group/status (kept in sync on add/edit), so
|
||||
# deleting just hard-removes the RADIUS/billing rows and soft-deletes metadata;
|
||||
# the mirrored group/status stay frozen on the row for reference.
|
||||
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(
|
||||
update(RadadminClient)
|
||||
.where(RadadminClient.mac_address == mac, RadadminClient.deleted_at.is_(None))
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
db.commit()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Device management — the NAS/AP boxes seen in accounting.
|
||||
|
||||
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 sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import RadadminDevice, RadAcct
|
||||
from ..schemas import DeviceAliasUpdate, DeviceOut
|
||||
|
||||
router = APIRouter(prefix="/device", tags=["device"])
|
||||
|
||||
|
||||
def _norm_mac(mac: str) -> str:
|
||||
"""Canonical AP MAC — uppercase, hyphen-separated (matches client MAC form)."""
|
||||
return mac.strip().upper().replace(":", "-")
|
||||
|
||||
|
||||
def _split_called(called: str | None) -> tuple[str | None, str | None]:
|
||||
"""radacct.calledstationid is ``<AP-MAC>:<SSID>`` — split into (mac, ssid).
|
||||
|
||||
The MAC is normalized to uppercase-hyphen; the SSID is left verbatim.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
]
|
||||
|
||||
|
||||
@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()
|
||||
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)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Read-only activity log. Admin-only: shows every state-changing action and
|
||||
auth event, filterable by acting user."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..models import Admin, LogEntry
|
||||
from ..pagination import Page, PageParams
|
||||
from ..schemas import LogOut
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[LogOut])
|
||||
def list_logs(
|
||||
page: PageParams = Depends(),
|
||||
username: str | None = None,
|
||||
action: str | None = None,
|
||||
_: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(LogEntry)
|
||||
count_stmt = select(func.count()).select_from(LogEntry)
|
||||
if username:
|
||||
stmt = stmt.where(LogEntry.username == username)
|
||||
count_stmt = count_stmt.where(LogEntry.username == username)
|
||||
if action:
|
||||
stmt = stmt.where(LogEntry.action == action)
|
||||
count_stmt = count_stmt.where(LogEntry.action == action)
|
||||
|
||||
total = int(db.execute(count_stmt).scalar_one())
|
||||
rows = db.execute(
|
||||
stmt.order_by(LogEntry.id.desc()).limit(page.limit).offset(page.offset)
|
||||
).scalars().all()
|
||||
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Admin-portal user management. Every endpoint requires an admin — regular
|
||||
users cannot create, delete, or reset the password of any account (they can
|
||||
only change their own password via ``/auth/change-password``).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from .. import auth
|
||||
from ..auth import require_admin
|
||||
from ..database import get_db
|
||||
from ..errors import APIError
|
||||
from ..models import Admin, Session
|
||||
from ..schemas import AdminCreate, AdminOut, ResetPasswordRequest
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdminOut])
|
||||
def list_users(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||
return db.execute(select(Admin).order_by(Admin.id)).scalars().all()
|
||||
|
||||
|
||||
@router.post("", response_model=AdminOut, status_code=201)
|
||||
def create_user(
|
||||
payload: AdminCreate,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
exists = db.execute(
|
||||
select(Admin.id).where(Admin.username == payload.username)
|
||||
).scalar_one_or_none()
|
||||
if exists is not None:
|
||||
raise APIError(status_code=409, detail=f"User '{payload.username}' already exists")
|
||||
|
||||
user = Admin(
|
||||
username=payload.username,
|
||||
password_hash=auth.hash_password(payload.password),
|
||||
is_admin=payload.is_admin,
|
||||
must_change_password=payload.force_password_change,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="create_user",
|
||||
detail=f"Created user '{user.username}'" + (" (admin)" if user.is_admin else ""),
|
||||
request=request,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
if user_id == admin.id:
|
||||
raise APIError(status_code=400, detail="You cannot delete your own account")
|
||||
user = db.get(Admin, user_id)
|
||||
if user is None:
|
||||
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||
if user.is_admin:
|
||||
admin_count = db.execute(
|
||||
select(func.count()).select_from(Admin).where(Admin.is_admin.is_(True))
|
||||
).scalar_one()
|
||||
if admin_count <= 1:
|
||||
raise APIError(status_code=400, detail="Cannot delete the last administrator")
|
||||
|
||||
# Drop the user's sessions too so any live token stops working immediately.
|
||||
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||
db.delete(s)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="delete_user",
|
||||
detail=f"Deleted user '{user.username}'",
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password", status_code=204)
|
||||
def reset_password(
|
||||
user_id: int,
|
||||
payload: ResetPasswordRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(require_admin),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
"""Admin resets another user's password; they must change it on next login."""
|
||||
user = db.get(Admin, user_id)
|
||||
if user is None:
|
||||
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||
user.password_hash = auth.hash_password(payload.new_password)
|
||||
user.must_change_password = payload.force_password_change
|
||||
# Invalidate existing sessions so the old password/session can't be reused.
|
||||
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||
db.delete(s)
|
||||
db.commit()
|
||||
auth.record_log(
|
||||
db,
|
||||
username=admin.username,
|
||||
action="reset_password",
|
||||
detail=f"Reset password for '{user.username}'",
|
||||
request=request,
|
||||
)
|
||||
@@ -9,14 +9,91 @@ class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ---------- auth: login / session / current user ----------
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(max_length=64)
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class CurrentUser(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
# Optional: a forced first-login change skips it (the user just authenticated
|
||||
# with their current password to reach that screen). A normal self-service
|
||||
# change from the account page still requires it.
|
||||
current_password: str | None = Field(default=None, max_length=256)
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
|
||||
|
||||
# ---------- admin users (radadmin_admins) ----------
|
||||
class AdminOut(ORMModel):
|
||||
id: int
|
||||
username: str
|
||||
is_admin: bool
|
||||
must_change_password: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=256)
|
||||
is_admin: bool = False
|
||||
force_password_change: bool = True # require a reset on first login
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(min_length=6, max_length=256)
|
||||
force_password_change: bool = True # make the user reset again on next login
|
||||
|
||||
|
||||
# ---------- API keys (radadmin_api_keys) ----------
|
||||
class ApiKeyOut(ORMModel):
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str
|
||||
created_by: str | None = None
|
||||
created_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked: bool
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
||||
class ApiKeyCreated(ApiKeyOut):
|
||||
key: str # the raw key, shown only once at creation
|
||||
|
||||
|
||||
# ---------- activity log (radadmin_logs) ----------
|
||||
class LogOut(ORMModel):
|
||||
id: int
|
||||
username: str | None = None
|
||||
action: str
|
||||
detail: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------- 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 +104,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 +204,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 +231,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 +258,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,6 +283,52 @@ class DeviceEdit(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
# ---------- 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 ``ClientCreate`` inside the router so failures
|
||||
are collected per-row instead of rejecting the entire request.
|
||||
"""
|
||||
|
||||
mac_address: str | None = None
|
||||
group: str | None = None
|
||||
name: str | None = None
|
||||
phone: str | None = None
|
||||
alias: str | None = None
|
||||
|
||||
|
||||
class ClientImportRequest(BaseModel):
|
||||
devices: list[ClientImportRow]
|
||||
dry_run: bool = Field(default=False, description="Validate only; commit nothing")
|
||||
|
||||
|
||||
class ClientImportError(BaseModel):
|
||||
row: int # 1-based index within the submitted rows
|
||||
mac: str | None = None
|
||||
detail: str
|
||||
|
||||
|
||||
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[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 ----------
|
||||
class UserGroupBase(BaseModel):
|
||||
username: str = Field(max_length=64)
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .build/dev
|
||||
volumes:
|
||||
- ./:/app
|
||||
ports:
|
||||
- 8000:8000
|
||||
env_file:
|
||||
- .env
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Idempotent schema migrations for the radadmin API.
|
||||
|
||||
Applies incremental DDL that ``radadmin_schema.sql`` can't (it only CREATEs
|
||||
tables IF NOT EXISTS, so it never alters an existing one). Each step checks
|
||||
information_schema first, so this is safe to re-run and works on both MySQL and
|
||||
MariaDB. Uses the same DB connection settings as the app (.env / config.py).
|
||||
|
||||
Run from the backend/ directory:
|
||||
|
||||
python migrate.py # apply pending migrations
|
||||
python migrate.py --dry-run # show what would run, change nothing
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.database import engine
|
||||
|
||||
TABLE = "radadmin_clients"
|
||||
|
||||
|
||||
def _column_exists(conn, table: str, column: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _index_exists(conn, table: str, index: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t AND index_name = :i"
|
||||
),
|
||||
{"t": table, "i": index},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def _table_exists(conn, table: str) -> bool:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM information_schema.tables "
|
||||
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
return bool(row)
|
||||
|
||||
|
||||
# Each migration: (description, predicate that returns True when it still needs
|
||||
# to run, SQL to run). Ordered — later steps may depend on earlier ones.
|
||||
MIGRATIONS = [
|
||||
(
|
||||
"add radadmin_clients.groupname (mirror of radusergroup)",
|
||||
lambda c: not _column_exists(c, TABLE, "groupname"),
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN groupname VARCHAR(64) NULL AFTER alias",
|
||||
),
|
||||
(
|
||||
"add radadmin_clients.status (mirror of customers.status)",
|
||||
lambda c: not _column_exists(c, TABLE, "status"),
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN status VARCHAR(10) NULL AFTER groupname",
|
||||
),
|
||||
(
|
||||
"add radadmin_clients.deleted_at (soft-delete state)",
|
||||
lambda c: not _column_exists(c, TABLE, "deleted_at"),
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN deleted_at DATETIME NULL DEFAULT NULL AFTER created_at",
|
||||
),
|
||||
(
|
||||
"add radadmin_clients.active_mac (generated; NULL when soft-deleted)",
|
||||
lambda c: not _column_exists(c, TABLE, "active_mac"),
|
||||
f"ALTER TABLE {TABLE} ADD COLUMN active_mac VARCHAR(17) "
|
||||
"AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED",
|
||||
),
|
||||
(
|
||||
"drop old unique index uq_radadmin_clients_mac",
|
||||
lambda c: _index_exists(c, TABLE, "uq_radadmin_clients_mac"),
|
||||
f"ALTER TABLE {TABLE} DROP INDEX uq_radadmin_clients_mac",
|
||||
),
|
||||
(
|
||||
"add partial-unique index uq_radadmin_clients_active_mac",
|
||||
lambda c: not _index_exists(c, TABLE, "uq_radadmin_clients_active_mac"),
|
||||
f"ALTER TABLE {TABLE} ADD UNIQUE KEY uq_radadmin_clients_active_mac (active_mac)",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Apply radadmin schema migrations.")
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="show pending migrations without applying them"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with engine.connect() as conn:
|
||||
if not _table_exists(conn, TABLE):
|
||||
print(
|
||||
f"error: table '{TABLE}' not found — import radadmin_schema.sql first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
applied = 0
|
||||
for desc, needs_run, sql in MIGRATIONS:
|
||||
if not needs_run(conn):
|
||||
print(f" skip {desc} (already applied)")
|
||||
continue
|
||||
if args.dry_run:
|
||||
print(f" PEND {desc}\n {sql}")
|
||||
applied += 1
|
||||
continue
|
||||
print(f" apply {desc}")
|
||||
conn.execute(text(sql))
|
||||
conn.commit() # DDL auto-commits, but be explicit for the connection
|
||||
applied += 1
|
||||
|
||||
if args.dry_run:
|
||||
print(f"\n{applied} migration(s) pending. Re-run without --dry-run to apply.")
|
||||
elif applied:
|
||||
print(f"\nDone — {applied} migration(s) applied.")
|
||||
else:
|
||||
print("\nDatabase already up to date.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Tables required by the radadmin API that are NOT part of the FreeRADIUS
|
||||
-- schema. FreeRADIUS never reads or writes these; they hold billing status and
|
||||
-- human-only metadata (name/phone/alias). Import this after the FreeRADIUS
|
||||
-- schema.sql.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
status VARCHAR(10) NOT NULL DEFAULT 'new',
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Soft delete: deleting a client hard-removes its radcheck/radusergroup/customers
|
||||
-- rows but only SETS deleted_at here, so the human metadata survives and can be
|
||||
-- restored/referenced. deleted_at IS NULL means "live". Uniqueness on mac_address
|
||||
-- is enforced only among live rows via the generated active_mac column (NULL for
|
||||
-- soft-deleted rows — MySQL/MariaDB allows many NULLs in a UNIQUE index), so a
|
||||
-- re-added client coexists with any number of old soft-deleted copies.
|
||||
-- groupname/status mirror the client's current group (radusergroup) and billing
|
||||
-- status (customers): populated on add and kept in sync on edit. They stay on the
|
||||
-- row when the client is soft-deleted, so a deleted client's group/status aren't
|
||||
-- lost along with its hard-deleted RADIUS/billing rows.
|
||||
CREATE TABLE IF NOT EXISTS radadmin_clients (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
name VARCHAR(128) NULL,
|
||||
phone VARCHAR(32) NULL,
|
||||
alias VARCHAR(64) NULL,
|
||||
groupname VARCHAR(64) NULL,
|
||||
status VARCHAR(10) NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME NULL DEFAULT NULL,
|
||||
active_mac VARCHAR(17) AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_radadmin_clients_active_mac (active_mac)
|
||||
);
|
||||
|
||||
-- Soft-delete migration for radadmin_clients tables created before this change
|
||||
-- (MariaDB syntax; idempotent, and a no-op on the fresh schema created above).
|
||||
ALTER TABLE radadmin_clients
|
||||
ADD COLUMN IF NOT EXISTS groupname VARCHAR(64) NULL AFTER alias;
|
||||
ALTER TABLE radadmin_clients
|
||||
ADD COLUMN IF NOT EXISTS status VARCHAR(10) NULL AFTER groupname;
|
||||
ALTER TABLE radadmin_clients
|
||||
ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL DEFAULT NULL AFTER created_at;
|
||||
ALTER TABLE radadmin_clients
|
||||
ADD COLUMN IF NOT EXISTS active_mac VARCHAR(17)
|
||||
AS (IF(deleted_at IS NULL, mac_address, NULL)) STORED;
|
||||
ALTER TABLE radadmin_clients
|
||||
DROP INDEX IF EXISTS uq_radadmin_clients_mac;
|
||||
ALTER TABLE radadmin_clients
|
||||
ADD UNIQUE KEY IF NOT EXISTS uq_radadmin_clients_active_mac (active_mac);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radadmin_devices (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
alias VARCHAR(64) NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_radadmin_devices_mac (mac_address)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Admin portal auth. Replaces the old single shared X-API-Key env secret with
|
||||
-- real per-user logins, roles, revocable API keys, and an activity log.
|
||||
--
|
||||
-- The API seeds a default "admin" / "admin" account on first startup when
|
||||
-- radadmin_admins is empty (must_change_password = 1 forces a reset on the
|
||||
-- first login). No password hashes are stored in this file so the seed always
|
||||
-- matches the app's hashing scheme.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Admin portal users. password_hash is PBKDF2-HMAC-SHA256, formatted as
|
||||
-- "pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>". is_admin grants user
|
||||
-- management, API-key management and activity-log access.
|
||||
CREATE TABLE IF NOT EXISTS radadmin_admins (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
must_change_password TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_radadmin_admins_username (username)
|
||||
);
|
||||
|
||||
-- Seed the default admin/admin login at import time so it exists regardless of
|
||||
-- whether the API has started yet. INSERT IGNORE makes this idempotent: it is
|
||||
-- skipped once an 'admin' row exists, so it never clobbers a changed password.
|
||||
-- The hash below is PBKDF2-HMAC-SHA256 of "admin"; must_change_password = 1
|
||||
-- forces a reset on first sign-in. (The API also seeds this on first startup.)
|
||||
INSERT IGNORE INTO radadmin_admins (username, password_hash, is_admin, must_change_password)
|
||||
VALUES (
|
||||
'admin',
|
||||
'pbkdf2_sha256$240000$b03a4d5fdc007ba832d5302dc787666d$f1c1a75c58af12402c2c9f2517ca2a89ab13eb5b8613929fb60f408559e48e79',
|
||||
1, 1
|
||||
);
|
||||
|
||||
-- Opaque login sessions. token is a random secret handed to the browser and
|
||||
-- sent back as "Authorization: Bearer <token>". Rows are deleted on logout and
|
||||
-- ignored/cleaned once expires_at passes.
|
||||
CREATE TABLE IF NOT EXISTS radadmin_sessions (
|
||||
token CHAR(64) NOT NULL,
|
||||
admin_id INT UNSIGNED NOT NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (token),
|
||||
KEY idx_radadmin_sessions_admin (admin_id),
|
||||
CONSTRAINT fk_radadmin_sessions_admin
|
||||
FOREIGN KEY (admin_id) REFERENCES radadmin_admins (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Programmatic API keys (created by admins only). The raw key is shown once at
|
||||
-- creation; only its PBKDF2 hash is stored. key_prefix is the first few
|
||||
-- characters, kept in clear so keys are identifiable in the UI. Clients send
|
||||
-- the raw key in the X-API-Key header.
|
||||
CREATE TABLE IF NOT EXISTS radadmin_api_keys (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
key_prefix VARCHAR(16) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
created_by VARCHAR(64) NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- Activity log: one row per state-changing action or auth event, with the
|
||||
-- acting username, what they did, and where from. Admin-only in the UI.
|
||||
CREATE TABLE IF NOT EXISTS radadmin_logs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NULL,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
detail VARCHAR(512) NULL,
|
||||
ip_address VARCHAR(45) NULL,
|
||||
created_at DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_radadmin_logs_username (username),
|
||||
KEY idx_radadmin_logs_created (created_at)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
include:
|
||||
- frontend/compose.yml
|
||||
- backend/compose.yml
|
||||
@@ -1,71 +0,0 @@
|
||||
|
||||
adding a new device
|
||||
example post /device/new
|
||||
macaddress,group
|
||||
removing an existing device
|
||||
|
||||
listing vlans
|
||||
GET /vlan/
|
||||
SELECT * FROM `radgroupreply`
|
||||
this will return 3 duplicate "groupnames" with different attribute and vlaue
|
||||
only need to return group name and value for Tunnel-Private-Group-Id
|
||||
|
||||
adding new vlan
|
||||
need to add 3 items with different attribute and value,
|
||||
for example to add staff vlan with vlan ID 55
|
||||
api call should be like POST vlan/add and json data vlanid:55, alias staff
|
||||
|
||||
'INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
|
||||
VALUES ('staff', 'Tunnel-Type', '=', 'VLAN');
|
||||
|
||||
INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
|
||||
VALUES ('staff', 'Tunnel-Medium-Type', '=', 'IEEE-802');
|
||||
|
||||
INSERT INTO `radgroupreply` (`groupname`, `attribute`, `op`, `value`)
|
||||
VALUES ('staff', 'Tunnel-Private-Group-Id', '=', '55');
|
||||
'
|
||||
deleting existing vlan
|
||||
you get api call to delete vlan
|
||||
DELETE vlan/55
|
||||
and the sqlcommands should you looking for Tunnel-Private-Group-Id=55 and then deleting all the maching group names
|
||||
|
||||
edit existing vlan (alias)
|
||||
post request, and allow to edit alias
|
||||
|
||||
disallow to add duplicate vlans or alias
|
||||
|
||||
listing existing devices
|
||||
get /device/
|
||||
SELECT * FROM `radusergroup`
|
||||
and then
|
||||
SELECT * FROM `customers`
|
||||
return mac address, groupname and status
|
||||
|
||||
adding new device
|
||||
this input feils are
|
||||
'
|
||||
POST /device/add with mac address and group and it involves few Commands
|
||||
`
|
||||
SELECT * FROM `radgroupreply`
|
||||
# and see if the group in the device add command exists
|
||||
# respond 4xx (whatever needed with reason group not found)
|
||||
# else continue
|
||||
|
||||
INSERT INTO `radcheck` (`username`, `attribute`, `op`, `value`)
|
||||
VALUES ('14-99-3E-74-CB-7F', 'Cleartext-Password', ':=', '14-99-3E-74-CB-7F');
|
||||
|
||||
INSERT INTO `radusergroup` (`username`, `groupname`, `priority`)
|
||||
VALUES ('14-99-3E-74-CB-7F', 'staff', '1');
|
||||
|
||||
INSERT INTO `customers` (`username`, `mac_address`, `status`, `created_at`)
|
||||
VALUES ('14-99-3E-74-CB-7F', '14-99-3E-74-CB-7F', 'paid', now());
|
||||
`
|
||||
|
||||
deleting existing deivce
|
||||
edit existing device (input macaddress, edit groupname, edit status. do allow to edit just 1 feild)
|
||||
use sesnible commands based on add devices
|
||||
|
||||
i would also like a device alias, added to customers table like
|
||||
"Customer name, device name, phone number"
|
||||
these are to be ignored by radius server, but helpful for human review
|
||||
and add input fields during adding device and also listing devices
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM node:22.17.0-alpine
|
||||
|
||||
WORKDIR /var/www/html/
|
||||
|
||||
CMD npm install && npm run dev -- --host 0.0.0.0
|
||||
@@ -0,0 +1,11 @@
|
||||
# Frontend config for radui. Copy to `.env` and adjust as needed.
|
||||
# NOTE: the API *key* is NOT set here — it is entered on the login screen and
|
||||
# stored in the browser. Nothing in this file is secret.
|
||||
|
||||
# Dev only: where Vite proxies /api/* to (the radapi backend).
|
||||
VITE_API_TARGET=http://10.0.1.235:8000
|
||||
|
||||
# Base path the client calls. Leave as /api to use the dev proxy (same-origin,
|
||||
# no CORS). For a production build served on a different origin than the API,
|
||||
# set this to the API's absolute URL, e.g. https://radapi.example.com
|
||||
VITE_API_BASE=/api
|
||||
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# radui — RADIUS Admin Portal
|
||||
|
||||
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. Admins also manage **users**, **API keys**, and view an
|
||||
**activity log**. Auth is a username/password login; the backend returns a session
|
||||
token stored in the browser (`localStorage`) and sent as `Authorization: Bearer`
|
||||
on every request.
|
||||
|
||||
## Stack
|
||||
|
||||
- React 19 + Vite + TypeScript
|
||||
- Tailwind v4 + shadcn/ui-style components (Radix primitives)
|
||||
- react-router-dom, sonner (toasts)
|
||||
- Node provided via `shell.nix` (NixOS) — no global install needed
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
nix-shell # drops you into a shell with node 22 + npm
|
||||
npm install # first time only
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Or as a one-liner without entering the shell:
|
||||
|
||||
```bash
|
||||
nix-shell --run "npm run dev -- --host 0.0.0.0"
|
||||
```
|
||||
|
||||
### Talking to the API
|
||||
|
||||
In dev, Vite proxies `/api/*` to the backend so the browser makes same-origin
|
||||
requests (no CORS) and the token is only ever sent as a header. The target defaults
|
||||
to `http://10.0.1.235:8000`; override it:
|
||||
|
||||
```bash
|
||||
VITE_API_TARGET=http://192.168.1.21:8000 npm run dev
|
||||
```
|
||||
|
||||
Log in with **`admin` / `admin`** on first run (you'll be prompted to change the
|
||||
password), then create real users from the **Users** page. A `401` from any request
|
||||
clears the stored token and returns you to the login screen.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # tsc -b && vite build → dist/
|
||||
npm run preview # serve the production build locally
|
||||
```
|
||||
|
||||
For production the built `dist/` is static — serve it behind the same origin as the
|
||||
API (or set `VITE_API_BASE` to the API's absolute URL at build time).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
main.tsx providers (router, auth, toaster)
|
||||
App.tsx auth gate + forced-password-change + nav (role-gated) + routes
|
||||
auth/auth.tsx session auth context (login/logout, current user, 401 handling)
|
||||
lib/api.ts typed API client (auth + users + apikeys + logs + client/device/vlan)
|
||||
lib/utils.ts cn() helper
|
||||
pages/
|
||||
Login.tsx username/password login screen
|
||||
ChangePassword.tsx change own password (also the forced first-login screen)
|
||||
Users.tsx admin: list/create/delete users + reset password
|
||||
ApiKeys.tsx admin: create (shown once) / list / revoke API keys
|
||||
Logs.tsx admin: paginated activity log
|
||||
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/
|
||||
ClientImportExport.tsx CSV import (preview/confirm) + export dialog
|
||||
ui/ button, input, label, dialog, select, table, card, badge, sonner
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **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`).
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .build/dev
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- ./:/var/www/html/
|
||||
ports:
|
||||
- 5173:5173
|
||||
env_file:
|
||||
- .env
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RADIUS Admin</title>
|
||||
<!-- Critical: paint the correct background before the app CSS loads (avoids flash). -->
|
||||
<style>
|
||||
html { background: oklch(1 0 0); color-scheme: light; }
|
||||
html.dark { background: oklch(0.145 0 0); color-scheme: dark; }
|
||||
</style>
|
||||
<script>
|
||||
// Apply saved (or system) theme before first paint.
|
||||
(function () {
|
||||
var t = localStorage.getItem('radui.theme')
|
||||
if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2921
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "radui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"papaparse": "^5.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"oxlint": "^1.75.0",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,11 @@
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "radui dev shell — node $(node --version), npm $(npm --version)"
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Router,
|
||||
ScrollText,
|
||||
Sun,
|
||||
UserCog,
|
||||
Users as UsersIcon,
|
||||
Wifi,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
import { useTheme } from '@/lib/theme'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Clients } from '@/pages/Clients'
|
||||
import { Devices } from '@/pages/Devices'
|
||||
import { Vlans } from '@/pages/Vlans'
|
||||
import { Users } from '@/pages/Users'
|
||||
import { ApiKeys } from '@/pages/ApiKeys'
|
||||
import { Logs } from '@/pages/Logs'
|
||||
import { ChangePassword } from '@/pages/ChangePassword'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function ThemeToggle() {
|
||||
const { theme, toggle } = useTheme()
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggle}
|
||||
className="text-muted-foreground"
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
type NavItem = { to: string; label: string; icon: typeof Wifi }
|
||||
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent',
|
||||
)
|
||||
|
||||
function SidebarContent({
|
||||
username,
|
||||
isAdmin,
|
||||
onNavigate,
|
||||
}: {
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const { logout } = useAuth()
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ to: '/clients', label: 'Clients', icon: UsersIcon },
|
||||
{ to: '/devices', label: 'Devices', icon: Router },
|
||||
{ to: '/vlans', label: 'VLANs', icon: Wifi },
|
||||
]
|
||||
const adminItems: NavItem[] = [
|
||||
{ to: '/users', label: 'Users', icon: UserCog },
|
||||
{ to: '/apikeys', label: 'API Keys', icon: KeyRound },
|
||||
{ to: '/logs', label: 'Activity Log', icon: ScrollText },
|
||||
]
|
||||
|
||||
const renderItem = ({ to, label, icon: Icon }: NavItem) => (
|
||||
<NavLink key={to} to={to} className={linkClass} onClick={onNavigate}>
|
||||
<Icon className="h-4 w-4 shrink-0" /> {label}
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-14 items-center gap-2 px-4 font-semibold">
|
||||
<Wifi className="h-5 w-5 text-primary" />
|
||||
RADIUS Admin
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-2">
|
||||
{items.map(renderItem)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="mt-4 px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Admin
|
||||
</div>
|
||||
{adminItems.map(renderItem)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<div className="border-t p-3">
|
||||
<NavLink to="/account" className={linkClass} onClick={onNavigate} title="Change password">
|
||||
<UserCog className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{username}</span>
|
||||
</NavLink>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-1 w-full justify-start gap-3 px-3 text-muted-foreground"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4 shrink-0" /> Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({
|
||||
username,
|
||||
isAdmin,
|
||||
children,
|
||||
}: {
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// Close the mobile drawer whenever the route changes.
|
||||
useEffect(() => {
|
||||
setOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/20">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 border-r bg-background md:block">
|
||||
<SidebarContent username={username} isAdmin={isAdmin} />
|
||||
</aside>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside className="absolute inset-y-0 left-0 w-64 border-r bg-background shadow-lg">
|
||||
<SidebarContent
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center gap-2 border-b bg-background px-4 md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Wifi className="h-5 w-5 text-primary" />
|
||||
RADIUS Admin
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="md:pl-60">
|
||||
{/* Desktop top-right theme toggle */}
|
||||
<div className="hidden items-center justify-end px-6 pt-4 md:flex">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) return <Login />
|
||||
|
||||
if (user.must_change_password) return <ChangePassword forced />
|
||||
|
||||
const admin = user.is_admin
|
||||
|
||||
return (
|
||||
<Shell username={user.username} isAdmin={admin}>
|
||||
<Routes>
|
||||
<Route path="/clients" element={<Clients />} />
|
||||
<Route path="/devices" element={<Devices />} />
|
||||
<Route path="/vlans" element={<Vlans />} />
|
||||
<Route path="/account" element={<ChangePassword />} />
|
||||
<Route path="/users" element={admin ? <Users /> : <Navigate to="/clients" replace />} />
|
||||
<Route
|
||||
path="/apikeys"
|
||||
element={admin ? <ApiKeys /> : <Navigate to="/clients" replace />}
|
||||
/>
|
||||
<Route path="/logs" element={admin ? <Logs /> : <Navigate to="/clients" replace />} />
|
||||
<Route path="*" element={<Navigate to="/clients" replace />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
clearToken,
|
||||
getToken,
|
||||
setToken,
|
||||
setUnauthorizedHandler,
|
||||
type CurrentUser,
|
||||
} from '@/lib/api'
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null
|
||||
authenticated: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<CurrentUser | null>(null)
|
||||
|
||||
const logout = useCallback(() => {
|
||||
api.auth.logout().catch(() => {}) // best effort; token is cleared regardless
|
||||
clearToken()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
// Any 401 from the API layer forces a logout so the login screen reappears.
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// On mount, if a token exists, load the current user; drop the token if it's stale.
|
||||
useEffect(() => {
|
||||
if (!getToken()) return
|
||||
api.auth
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch(() => {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
try {
|
||||
const res = await api.auth.login(username, password)
|
||||
setToken(res.token)
|
||||
// Fetch the full profile (including id) with the fresh token.
|
||||
setUser(await api.auth.me())
|
||||
} catch (err) {
|
||||
clearToken()
|
||||
setUser(null)
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
throw new Error('Invalid username or password')
|
||||
}
|
||||
throw err instanceof Error ? err : new Error('Could not reach the API.')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setUser(await api.auth.me())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext value={{ user, authenticated: !!user, login, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import Papa from 'papaparse'
|
||||
import { AlertTriangle, ArrowDownUp, Download, FileText, Loader2, Upload } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type Client,
|
||||
type ClientImportResult,
|
||||
type ClientImportRow,
|
||||
} from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
const IMPORT_COLUMNS = ['mac_address', 'group', 'name', 'phone', 'alias'] as const
|
||||
const EXPORT_COLUMNS = [...IMPORT_COLUMNS, 'status'] as const
|
||||
|
||||
const EXAMPLE_CSV = Papa.unparse({
|
||||
fields: [...IMPORT_COLUMNS],
|
||||
data: [
|
||||
['AA-BB-CC-DD-EE-FF', 'customer', 'Ali Hassan', '7712345', 'Living Room TV'],
|
||||
['11-22-33-44-55-66', 'staff', 'Sara Ibrahim', '9998887', ''],
|
||||
],
|
||||
})
|
||||
|
||||
function downloadCsv(filename: string, csv: string) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function parseCsv(file: File): Promise<ClientImportRow[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Papa.parse<Record<string, string>>(file, {
|
||||
header: true,
|
||||
skipEmptyLines: 'greedy',
|
||||
transformHeader: (h) => h.trim().toLowerCase(),
|
||||
complete: (res) => {
|
||||
const rows = res.data.map((r) => {
|
||||
const pick = (k: string) => (r[k] ?? '').trim() || undefined
|
||||
return {
|
||||
mac_address: pick('mac_address'),
|
||||
group: pick('group'),
|
||||
name: pick('name'),
|
||||
phone: pick('phone'),
|
||||
alias: pick('alias'),
|
||||
}
|
||||
})
|
||||
resolve(rows)
|
||||
},
|
||||
error: (err) => reject(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function ClientImportExport({ onImported }: { onImported: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
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)
|
||||
|
||||
function reset() {
|
||||
setRows(null)
|
||||
setPreview(null)
|
||||
setBusy(false)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
|
||||
function openChange(o: boolean) {
|
||||
setOpen(o)
|
||||
if (!o) reset()
|
||||
}
|
||||
|
||||
async function onFile(file: File) {
|
||||
setBusy(true)
|
||||
try {
|
||||
const parsed = await parseCsv(file)
|
||||
if (parsed.length === 0) {
|
||||
toast.error('No rows found in that CSV')
|
||||
return
|
||||
}
|
||||
const result = await api.clients.import(parsed, true) // dry run
|
||||
setRows(parsed)
|
||||
setPreview(result)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to read CSV')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
if (!rows) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.clients.import(rows, false)
|
||||
const skipped = res.errors.length
|
||||
toast.success(
|
||||
`Imported ${res.created} client${res.created === 1 ? '' : 's'}` +
|
||||
(skipped ? `, ${skipped} skipped` : ''),
|
||||
)
|
||||
onImported()
|
||||
openChange(false)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportAll() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const all: Client[] = []
|
||||
const limit = 500
|
||||
let offset = 0
|
||||
for (;;) {
|
||||
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
|
||||
}
|
||||
const csv = Papa.unparse({
|
||||
fields: [...EXPORT_COLUMNS],
|
||||
data: all.map((d) => [
|
||||
d.mac_address,
|
||||
d.group ?? '',
|
||||
d.name ?? '',
|
||||
d.phone ?? '',
|
||||
d.alias ?? '',
|
||||
d.status ?? '',
|
||||
]),
|
||||
})
|
||||
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')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<ArrowDownUp className="h-4 w-4" /> Import / Export
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={openChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import / Export clients</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bulk-add clients from a CSV, or download all clients as a CSV.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{preview ? (
|
||||
<ImportPreview
|
||||
preview={preview}
|
||||
busy={busy}
|
||||
onConfirm={confirmImport}
|
||||
onBack={reset}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Example */}
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Example CSV</div>
|
||||
<div className="text-muted-foreground">
|
||||
Columns: <span className="font-mono text-xs">{IMPORT_COLUMNS.join(', ')}</span>{' '}
|
||||
— <span className="font-mono text-xs">alias</span> optional.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadCsv('clients-template.csv', EXAMPLE_CSV)}
|
||||
>
|
||||
<FileText className="h-4 w-4" /> Download
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Import */}
|
||||
<section className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">Import from CSV</div>
|
||||
<div className="text-muted-foreground">
|
||||
You'll see a preview and confirm before anything is saved.
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
Choose file
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) onFile(f)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Export */}
|
||||
<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 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 ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportPreview({
|
||||
preview,
|
||||
busy,
|
||||
onConfirm,
|
||||
onBack,
|
||||
}: {
|
||||
preview: ClientImportResult
|
||||
busy: boolean
|
||||
onConfirm: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
const hasErrors = preview.errors.length > 0
|
||||
const nothingValid = preview.valid === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<span className="rounded-md bg-muted px-2 py-1">{preview.total} rows</span>
|
||||
<span className="rounded-md bg-emerald-500/10 px-2 py-1 text-emerald-600 dark:text-emerald-400">
|
||||
{preview.valid} valid
|
||||
</span>
|
||||
{hasErrors && (
|
||||
<span className="rounded-md bg-destructive/10 px-2 py-1 text-destructive">
|
||||
{preview.errors.length} with errors
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasErrors && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
These rows will be skipped:
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y">
|
||||
{preview.errors.map((e) => (
|
||||
<tr key={`${e.row}-${e.mac ?? ''}`} className="align-top">
|
||||
<td className="w-14 px-3 py-2 text-muted-foreground">#{e.row}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{e.mac || '—'}</td>
|
||||
<td className="px-3 py-2 text-destructive">{e.detail}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nothingValid ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No valid rows to import. Fix the CSV and try again.
|
||||
</p>
|
||||
) : (
|
||||
<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} client${preview.valid === 1 ? '' : 's'}?`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onBack} disabled={busy}>
|
||||
{hasErrors ? 'Cancel & retry' : 'Back'}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={busy || nothingValid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{hasErrors ? `Add anyway (${preview.valid})` : `Import ${preview.valid}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100, 200] as const
|
||||
const DEFAULT_PAGE_SIZE = 10
|
||||
const STORAGE_KEY = 'radui.pageSize'
|
||||
|
||||
/** Rows-per-page preference, shared across tables and persisted in localStorage. */
|
||||
export function usePageSize() {
|
||||
const [pageSize, setPageSizeState] = useState<number>(() => {
|
||||
const raw = Number(localStorage.getItem(STORAGE_KEY))
|
||||
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(raw) ? raw : DEFAULT_PAGE_SIZE
|
||||
})
|
||||
const setPageSize = (n: number) => {
|
||||
setPageSizeState(n)
|
||||
localStorage.setItem(STORAGE_KEY, String(n))
|
||||
}
|
||||
return [pageSize, setPageSize] as const
|
||||
}
|
||||
|
||||
/** Client-side pagination over an already-loaded array. */
|
||||
export function usePagination<T>(items: T[], pageSize: number) {
|
||||
const [page, setPage] = useState(1)
|
||||
const pageCount = Math.max(1, Math.ceil(items.length / pageSize))
|
||||
|
||||
// Reset to the first page when the page size changes.
|
||||
useEffect(() => setPage(1), [pageSize])
|
||||
// Clamp the current page when the list shrinks (e.g. after a delete/filter).
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount)
|
||||
}, [page, pageCount])
|
||||
|
||||
const pageItems = useMemo(
|
||||
() => items.slice((page - 1) * pageSize, page * pageSize),
|
||||
[items, page, pageSize],
|
||||
)
|
||||
|
||||
return { page, setPage, pageCount, pageItems, total: items.length }
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
pageCount,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
page: number
|
||||
pageCount: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
}) {
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||
const to = Math.min(page * pageSize, total)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{from}–{to} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
title="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span>
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
title="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="ml-2">Rows per page</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-[4.5rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-emerald-100 text-emerald-800',
|
||||
warning: 'border-transparent bg-amber-100 text-amber-800',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-6 pt-0', className)} {...props} />
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/50 backdrop-blur-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5 text-left', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn('p-1', position === 'popper' && 'w-full min-w-[var(--radix-select-trigger-width)]')}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
function Toaster(props: ToasterProps) {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
position="top-right"
|
||||
richColors
|
||||
closeButton
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
@@ -0,0 +1,85 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// Typed client for the radapi FreeRADIUS REST API.
|
||||
// Base URL is /api in dev (proxied by Vite to the real API) so the browser
|
||||
// makes same-origin requests and the API key travels only as a header.
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? '/api'
|
||||
const TOKEN_STORAGE = 'radui.token'
|
||||
const OLD_KEY_STORAGE = 'radui.apiKey'
|
||||
|
||||
// ---- Session token storage ----
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_STORAGE)
|
||||
}
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_STORAGE, token)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_STORAGE)
|
||||
}
|
||||
// Drop the legacy X-API-Key value left over from the old auth model.
|
||||
localStorage.removeItem(OLD_KEY_STORAGE)
|
||||
|
||||
// A 401 anywhere means the stored token is bad/expired — notify the app to log out.
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
type ValidationItem = { loc: (string | number)[]; msg: string }
|
||||
|
||||
function messageFromDetail(detail: unknown, fallback: string): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
return (detail as ValidationItem[])
|
||||
.map((d) => {
|
||||
const field = d.loc?.filter((p) => p !== 'body').join('.')
|
||||
return field ? `${field}: ${d.msg}` : d.msg
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers = new Headers(options.headers)
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
if (options.body) headers.set('Content-Type', 'application/json')
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.()
|
||||
throw new ApiError(401, 'Session expired or unauthorized')
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
let payload: unknown = null
|
||||
const text = await res.text()
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
payload = text
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = (payload as { detail?: unknown })?.detail ?? payload
|
||||
throw new ApiError(res.status, messageFromDetail(detail, `Request failed (${res.status})`))
|
||||
}
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
// ---- Types ----
|
||||
export type ClientStatus = 'new' | 'paid' | 'unpaid'
|
||||
|
||||
export interface Client {
|
||||
mac_address: string
|
||||
group: string | null
|
||||
status: ClientStatus | null
|
||||
name: string | null
|
||||
phone: string | null
|
||||
alias: string | null
|
||||
}
|
||||
|
||||
export interface ClientCreate {
|
||||
mac_address: string
|
||||
group: string
|
||||
name: string
|
||||
phone: string
|
||||
alias?: string | null
|
||||
}
|
||||
|
||||
export interface ClientEdit {
|
||||
mac_address: string
|
||||
group?: string
|
||||
status?: ClientStatus
|
||||
name?: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface ClientFilters {
|
||||
search?: string
|
||||
status?: ClientStatus
|
||||
}
|
||||
|
||||
export interface ClientImportRow {
|
||||
mac_address?: string
|
||||
group?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
alias?: string
|
||||
}
|
||||
|
||||
export interface ClientImportRowError {
|
||||
row: number
|
||||
mac: string | null
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface ClientImportResult {
|
||||
total: number
|
||||
valid: number
|
||||
created: number
|
||||
dry_run: boolean
|
||||
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 {
|
||||
alias: string
|
||||
vlanid: number
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: T[]
|
||||
}
|
||||
|
||||
// ---- Auth / admin types ----
|
||||
export interface CurrentUser {
|
||||
id: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
must_change_password: boolean
|
||||
}
|
||||
|
||||
// A row from GET /users (admin view).
|
||||
export interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
is_admin: boolean
|
||||
must_change_password: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
token: string
|
||||
username: string
|
||||
is_admin: boolean
|
||||
must_change_password: boolean
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number
|
||||
name: string
|
||||
key_prefix: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
last_used_at: string | null
|
||||
revoked: boolean
|
||||
}
|
||||
|
||||
// POST /apikeys additionally returns the raw key exactly once.
|
||||
export interface ApiKeyCreated extends ApiKeyRow {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
id: number
|
||||
username: string
|
||||
action: string
|
||||
detail: string | null
|
||||
ip_address: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface LogFilters {
|
||||
username?: string
|
||||
action?: string
|
||||
}
|
||||
|
||||
// ---- Endpoints ----
|
||||
export const api = {
|
||||
auth: {
|
||||
login: (username: string, password: string) =>
|
||||
request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
logout: () => request<void>('/auth/logout', { method: 'POST' }),
|
||||
me: () => request<CurrentUser>('/auth/me'),
|
||||
// current_password is omitted on a forced first-login change (the backend
|
||||
// skips the check when the account is flagged must_change_password).
|
||||
changePassword: (new_password: string, current_password?: string) =>
|
||||
request<void>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(
|
||||
current_password ? { current_password, new_password } : { new_password },
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
users: {
|
||||
list: () => request<AdminUser[]>('/users'),
|
||||
create: (body: {
|
||||
username: string
|
||||
password: string
|
||||
is_admin: boolean
|
||||
force_password_change: boolean
|
||||
}) => request<AdminUser>('/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
remove: (id: number) => request<void>(`/users/${id}`, { method: 'DELETE' }),
|
||||
resetPassword: (id: number, new_password: string, force_password_change: boolean) =>
|
||||
request<void>(`/users/${id}/reset-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ new_password, force_password_change }),
|
||||
}),
|
||||
},
|
||||
|
||||
apikeys: {
|
||||
list: () => request<ApiKeyRow[]>('/apikeys'),
|
||||
create: (name: string) =>
|
||||
request<ApiKeyCreated>('/apikeys', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||
revoke: (id: number) => request<void>(`/apikeys/${id}`, { method: 'DELETE' }),
|
||||
},
|
||||
|
||||
logs: {
|
||||
list: (limit = 50, offset = 0, filters: LogFilters = {}) => {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||
if (filters.username) params.set('username', filters.username)
|
||||
if (filters.action) params.set('action', filters.action)
|
||||
return request<Page<LogRow>>(`/logs?${params.toString()}`)
|
||||
},
|
||||
},
|
||||
|
||||
clients: {
|
||||
list: (limit = 50, offset = 0, filters: ClientFilters = {}) => {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })
|
||||
if (filters.search?.trim()) params.set('search', filters.search.trim())
|
||||
if (filters.status) params.set('status', filters.status)
|
||||
return request<Page<Client>>(`/client/?${params.toString()}`)
|
||||
},
|
||||
get: (mac: string) => request<Client>(`/client/${encodeURIComponent(mac)}`),
|
||||
add: (body: ClientCreate) =>
|
||||
request<Client>('/client/add', { method: 'POST', body: JSON.stringify(body) }),
|
||||
edit: (body: ClientEdit) =>
|
||||
request<Client>('/client/edit', { method: 'POST', body: JSON.stringify(body) }),
|
||||
remove: (mac: string) =>
|
||||
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: 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) =>
|
||||
request<Vlan>('/vlan/add', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
|
||||
edit: (vlanid: number, alias: string) =>
|
||||
request<Vlan>('/vlan/edit', { method: 'POST', body: JSON.stringify({ vlanid, alias }) }),
|
||||
remove: (vlanid: number) => request<void>(`/vlan/${vlanid}`, { method: 'DELETE' }),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Mirrors the API's phone rule (radapi schemas._normalize_phone):
|
||||
// 7 digits starting with 9 or 7. A 960/+960 country code is stripped ONLY when the
|
||||
// number is 10 digits (960 + 7) — a bare 7-digit number like 9601234 is a valid
|
||||
// local number and is never stripped.
|
||||
|
||||
export const PHONE_ERROR = 'Enter a valid phone number'
|
||||
|
||||
/** Returns the normalized 7-digit local number, or null if invalid. */
|
||||
export function normalizePhone(raw: string): string | null {
|
||||
let digits = (raw ?? '').replace(/\D/g, '')
|
||||
if (digits.length === 10 && digits.startsWith('960')) {
|
||||
digits = digits.slice(3)
|
||||
}
|
||||
if (digits.length === 7 && (digits[0] === '9' || digits[0] === '7')) {
|
||||
return digits
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Error message if invalid, else null. */
|
||||
export function phoneError(raw: string): string | null {
|
||||
return normalizePhone(raw) === null ? PHONE_ERROR : null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
const STORAGE = 'radui.theme'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeState | null>(null)
|
||||
|
||||
function initialTheme(): Theme {
|
||||
const saved = localStorage.getItem(STORAGE)
|
||||
if (saved === 'light' || saved === 'dark') return saved
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark')
|
||||
localStorage.setItem(STORAGE, theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setTheme((t) => (t === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return <ThemeContext value={{ theme, toggle }}>{children}</ThemeContext>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeState {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './auth/auth.tsx'
|
||||
import { ThemeProvider } from './lib/theme.tsx'
|
||||
import { Toaster } from './components/ui/sonner.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Ban, Check, Copy, Loader2, Plus, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api, ApiError, type ApiKeyCreated, type ApiKeyRow } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
function fmt(ts: string | null) {
|
||||
return ts ? new Date(ts).toLocaleString() : '—'
|
||||
}
|
||||
|
||||
export function ApiKeys() {
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [revoking, setRevoking] = useState<ApiKeyRow | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setKeys(await api.apikeys.list())
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load API keys')
|
||||
} 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">API keys</h1>
|
||||
<p className="text-sm text-muted-foreground">{keys.length} keys</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus /> Create API key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Prefix</TableHead>
|
||||
<TableHead>Created by</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead className="w-24">Status</TableHead>
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && keys.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>
|
||||
) : keys.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
No API keys yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
keys.map((k) => (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{k.key_prefix}…</TableCell>
|
||||
<TableCell>{k.created_by}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{fmt(k.created_at)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{fmt(k.last_used_at)}</TableCell>
|
||||
<TableCell>
|
||||
{k.revoked ? (
|
||||
<Badge variant="destructive">Revoked</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!k.revoked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setRevoking(k)}
|
||||
title="Revoke"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<CreateApiKeyDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={load} />
|
||||
<RevokeApiKeyDialog apikey={revoking} onClose={() => setRevoking(null)} onRevoked={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [created, setCreated] = useState<ApiKeyCreated | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('')
|
||||
setCreated(null)
|
||||
setCopied(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.apikeys.create(name.trim())
|
||||
setCreated(res)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create API key')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!created) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.key)
|
||||
setCopied(true)
|
||||
toast.success('API key copied to clipboard')
|
||||
} catch {
|
||||
toast.error('Could not copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
{created ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API key created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this key now. For security it will not be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label>{created.name}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2 font-mono text-xs">
|
||||
{created.key}
|
||||
</code>
|
||||
<Button variant="outline" size="icon" onClick={copy} title="Copy">
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Generate a new API key for programmatic access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Name<span className="text-destructive"> *</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="billing-sync"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RevokeApiKeyDialog({
|
||||
apikey,
|
||||
onClose,
|
||||
onRevoked,
|
||||
}: {
|
||||
apikey: ApiKeyRow | null
|
||||
onClose: () => void
|
||||
onRevoked: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function confirm() {
|
||||
if (!apikey) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.apikeys.revoke(apikey.id)
|
||||
toast.success(`API key ${apikey.name} revoked`)
|
||||
onClose()
|
||||
onRevoked()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!apikey} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke API key?</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="font-medium">{apikey?.name}</span> will stop working immediately. 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" />}
|
||||
Revoke
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { KeyRound, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
import { api, ApiError } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
const MIN_LENGTH = 6
|
||||
|
||||
export function ChangePassword({ forced = false }: { forced?: boolean }) {
|
||||
const { refresh } = useAuth()
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const tooShort = next.length > 0 && next.length < MIN_LENGTH
|
||||
const mismatch = confirm.length > 0 && next !== confirm
|
||||
// On a forced first-login change the current password isn't asked for — the
|
||||
// user just signed in with it — so it isn't part of validation.
|
||||
const valid = (forced || current.length > 0) && next.length >= MIN_LENGTH && next === confirm
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await api.auth.changePassword(next, forced ? undefined : current)
|
||||
await refresh()
|
||||
toast.success('Password changed')
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
setConfirm('')
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to change password'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const form = (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{!forced && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current">Current password</Label>
|
||||
<Input
|
||||
id="current"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new">New password</Label>
|
||||
<Input
|
||||
id="new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
{tooShort && (
|
||||
<p className="text-xs text-destructive">Must be at least {MIN_LENGTH} characters.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
{mismatch && <p className="text-xs text-destructive">Passwords do not match.</p>}
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{busy ? 'Saving…' : 'Change password'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
|
||||
if (forced) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Change your password</CardTitle>
|
||||
<CardDescription>You must change your password before continuing.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{form}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-xl">Change password</CardTitle>
|
||||
<CardDescription>Update the password for your account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{form}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Loader2, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api, ApiError, type Client, type ClientStatus, type Vlan } from '@/lib/api'
|
||||
import { normalizePhone, phoneError } from '@/lib/phone'
|
||||
import { TablePagination, usePageSize } from '@/components/Pagination'
|
||||
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 [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Client | null>(null)
|
||||
const [deleting, setDeleting] = useState<Client | null>(null)
|
||||
|
||||
// Filters — `search` is the raw input; `debouncedSearch` is what we query with
|
||||
// so we don't fire a request on every keystroke.
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [status, setStatus] = useState<ClientStatus | 'all'>('all')
|
||||
const hasFilters = debouncedSearch.trim() !== '' || status !== 'all'
|
||||
|
||||
const [pageSize, setPageSize] = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
// A device must be assigned to a VLAN group, so adding one is impossible
|
||||
// until at least one VLAN exists.
|
||||
const noVlans = !loading && vlans.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [search])
|
||||
|
||||
// Server-side pagination + filtering: fetch only the current page's rows.
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [d, v] = await Promise.all([
|
||||
api.clients.list(pageSize, (page - 1) * pageSize, {
|
||||
search: debouncedSearch,
|
||||
status: status === 'all' ? undefined : status,
|
||||
}),
|
||||
api.vlans.list(),
|
||||
])
|
||||
setDevices(d.items)
|
||||
setTotal(d.total)
|
||||
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)
|
||||
}
|
||||
}, [page, pageSize, debouncedSearch, status])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// Reset to the first page when the page size or an active filter changes.
|
||||
useEffect(() => setPage(1), [pageSize, debouncedSearch, status])
|
||||
// Clamp the page when the total shrinks (e.g. after a delete).
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount)
|
||||
}, [page, pageCount])
|
||||
|
||||
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">
|
||||
{total} {hasFilters ? 'match' : '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)}
|
||||
disabled={noVlans}
|
||||
title={noVlans ? 'Add a VLAN before registering a device' : undefined}
|
||||
>
|
||||
<Plus /> Add device
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by MAC, name, phone or alias…"
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as ClientStatus | 'all')}>
|
||||
<SelectTrigger className="sm:w-40">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch('')
|
||||
setStatus('all')
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{noVlans && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||
No VLANs exist yet. <Link to="/vlans" className="font-medium underline">Add a VLAN</Link>{' '}
|
||||
before registering a device.
|
||||
</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">
|
||||
{hasFilters
|
||||
? 'No clients match your filters.'
|
||||
: '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>
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loader2, Pencil, RefreshCw, Wifi } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api, ApiError, type Device } from '@/lib/api'
|
||||
import { TablePagination, usePageSize, usePagination } from '@/components/Pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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'
|
||||
|
||||
function Ssids({ ssids }: { ssids: string[] }) {
|
||||
if (ssids.length === 0) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{ssids.map((ssid) => (
|
||||
<span key={ssid} className="inline-flex items-center gap-1.5">
|
||||
<Wifi className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{ssid}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Ips({ value }: { value: string | null }) {
|
||||
const ips = value?.split(',').map((ip) => ip.trim()).filter(Boolean) ?? []
|
||||
if (ips.length === 0) return <span className="text-muted-foreground">—</span>
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{ips.map((ip) => (
|
||||
<span key={ip}>{ip}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Devices() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<Device | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
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')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const [pageSize, setPageSize] = usePageSize()
|
||||
const { page, setPage, pageCount, pageItems, total } = usePagination(devices, pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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} 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>AP MAC</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>SSIDs</TableHead>
|
||||
<TableHead>Alias</TableHead>
|
||||
<TableHead className="w-16 text-right">Edit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && devices.length === 0 ? (
|
||||
<TableRow>
|
||||
<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={5} className="py-10 text-center text-muted-foreground">
|
||||
No devices have reported accounting yet.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageItems.map((d) => (
|
||||
<TableRow key={d.ap_mac}>
|
||||
<TableCell className="font-mono text-xs">{d.ap_mac}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Ips value={d.nasipaddress} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Ssids ssids={d.ssids} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{d.alias ?? <span className="text-muted-foreground">—</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditing(d)} title="Edit alias">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditAliasDialog device={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditAliasDialog({
|
||||
device,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
device: Device | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (device) setAlias(device.alias ?? '')
|
||||
}, [device])
|
||||
|
||||
async function submit() {
|
||||
if (!device) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const value = alias.trim() || null
|
||||
await api.devices.setAlias(device.ap_mac, value)
|
||||
toast.success(`Alias ${value ? 'saved' : 'cleared'} for ${device.ap_mac}`)
|
||||
onClose()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to save alias')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<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} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { LogIn, Loader2 } from 'lucide-react'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
export function Login() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const valid = username.trim() && password
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
|
||||
<LogIn className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">RADIUS Admin</CardTitle>
|
||||
<CardDescription>Sign in to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Loader2, RefreshCw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { api, ApiError, type LogRow } from '@/lib/api'
|
||||
import { TablePagination, usePageSize } from '@/components/Pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
|
||||
function fmt(ts: string) {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
export function Logs() {
|
||||
const [items, setItems] = useState<LogRow[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
|
||||
const [pageSize, setPageSize] = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.logs.list(pageSize, (page - 1) * pageSize, {
|
||||
username: username.trim() || undefined,
|
||||
})
|
||||
setItems(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load activity log')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, username])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => setPage(1), [pageSize, username])
|
||||
useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount)
|
||||
}, [page, pageCount])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Activity log</h1>
|
||||
<p className="text-sm text-muted-foreground">{total} events</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Filter by username…"
|
||||
className="w-56"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-48">Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
<TableHead className="w-36">IP address</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
No activity recorded.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground">{fmt(row.created_at)}</TableCell>
|
||||
<TableCell className="font-medium">{row.username}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.action}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.detail ?? '—'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{row.ip_address ?? '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import { KeyRound, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '@/auth/auth'
|
||||
import { api, ApiError, type AdminUser } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
function fmt(ts: string) {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
export function Users() {
|
||||
const { user } = useAuth()
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [resetting, setResetting] = useState<AdminUser | null>(null)
|
||||
const [deleting, setDeleting] = useState<AdminUser | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setUsers(await api.users.list())
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load users')
|
||||
} 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">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">{users.length} accounts</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus /> Add user
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead className="w-28">Role</TableHead>
|
||||
<TableHead className="w-40">Password</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||
No users.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.username}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.is_admin ? 'default' : 'secondary'}>
|
||||
{u.is_admin ? 'Admin' : 'User'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.must_change_password ? (
|
||||
<Badge variant="warning">Must change</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{fmt(u.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setResetting(u)}
|
||||
title="Reset password"
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
{u.username !== user?.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleting(u)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AddUserDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
|
||||
<ResetPasswordDialog user={resetting} onClose={() => setResetting(null)} onSaved={load} />
|
||||
<DeleteUserDialog user={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [forcePwChange, setForcePwChange] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setIsAdmin(false)
|
||||
setForcePwChange(true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const valid = username.trim() && password.length >= 6
|
||||
|
||||
async function submit() {
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.users.create({
|
||||
username: username.trim(),
|
||||
password,
|
||||
is_admin: isAdmin,
|
||||
force_password_change: forcePwChange,
|
||||
})
|
||||
toast.success(`User ${username.trim()} created`)
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to create user')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add user</DialogTitle>
|
||||
<DialogDescription>Create a new admin-portal account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field label="Username" required>
|
||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
|
||||
</Field>
|
||||
<Field label="Password (min 6 chars)" required>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
checked={isAdmin}
|
||||
onChange={(e) => setIsAdmin(e.target.checked)}
|
||||
/>
|
||||
Administrator
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
checked={forcePwChange}
|
||||
onChange={(e) => setForcePwChange(e.target.checked)}
|
||||
/>
|
||||
Force password change on first login
|
||||
</label>
|
||||
</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 user
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({
|
||||
user,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: AdminUser | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [forcePwChange, setForcePwChange] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setPassword('')
|
||||
setForcePwChange(true)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const valid = password.length >= 6
|
||||
|
||||
async function submit() {
|
||||
if (!user || !valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.users.resetPassword(user.id, password, forcePwChange)
|
||||
toast.success(`Password reset for ${user.username}`)
|
||||
onClose()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to reset password')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sets a new password for <span className="font-medium">{user?.username}</span>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field label="New password (min 6 chars)" required>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input accent-primary"
|
||||
checked={forcePwChange}
|
||||
onChange={(e) => setForcePwChange(e.target.checked)}
|
||||
/>
|
||||
Force password change on next login
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !valid}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Reset password
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteUserDialog({
|
||||
user,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
user: AdminUser | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function confirm() {
|
||||
if (!user) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.users.remove(user.id)
|
||||
toast.success(`User ${user.username} deleted`)
|
||||
onClose()
|
||||
onDeleted()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete user')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete user?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently removes <span className="font-medium">{user?.username}</span>.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
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 Vlan } from '@/lib/api'
|
||||
import { TablePagination, usePageSize, usePagination } from '@/components/Pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
export function Vlans() {
|
||||
const [vlans, setVlans] = useState<Vlan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Vlan | null>(null)
|
||||
const [deleting, setDeleting] = useState<Vlan | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setVlans(await api.vlans.list())
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError && err.status === 401))
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to load VLANs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const [pageSize, setPageSize] = usePageSize()
|
||||
const { page, setPage, pageCount, pageItems, total } = usePagination(vlans, pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">VLANs</h1>
|
||||
<p className="text-sm text-muted-foreground">{vlans.length} configured</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
<Button onClick={() => setAddOpen(true)}>
|
||||
<Plus /> Add VLAN
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-background">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">VLAN ID</TableHead>
|
||||
<TableHead>Alias</TableHead>
|
||||
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && vlans.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : vlans.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="py-10 text-center text-muted-foreground">
|
||||
No VLANs configured.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageItems.map((v) => (
|
||||
<TableRow key={v.vlanid}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">VLAN {v.vlanid}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{v.alias}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setEditing(v)} title="Rename">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleting(v)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AddVlanDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
|
||||
<RenameVlanDialog vlan={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||
<DeleteVlanDialog vlan={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddVlanDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [vlanid, setVlanid] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVlanid('')
|
||||
setAlias('')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const id = Number(vlanid)
|
||||
const valid = vlanid !== '' && Number.isInteger(id) && id >= 1 && id <= 4094 && alias.trim()
|
||||
|
||||
async function submit() {
|
||||
if (!valid) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.vlans.add(id, alias.trim())
|
||||
toast.success(`VLAN ${id} (${alias.trim()}) added`)
|
||||
onOpenChange(false)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to add VLAN')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add VLAN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Creates the Tunnel-Type / Medium-Type / Private-Group-Id rows for this group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field label="VLAN ID (1–4094)" required>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={4094}
|
||||
placeholder="55"
|
||||
value={vlanid}
|
||||
onChange={(e) => setVlanid(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Alias (group name)" required>
|
||||
<Input placeholder="staff" 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 VLAN
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RenameVlanDialog({
|
||||
vlan,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
vlan: Vlan | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [alias, setAlias] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (vlan) setAlias(vlan.alias)
|
||||
}, [vlan])
|
||||
|
||||
async function submit() {
|
||||
if (!vlan || !alias.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.vlans.edit(vlan.vlanid, alias.trim())
|
||||
toast.success(`VLAN ${vlan.vlanid} renamed to ${alias.trim()}`)
|
||||
onClose()
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to rename VLAN')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename VLAN {vlan?.vlanid}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Updates the group name across its radgroupreply rows. Device group assignments in
|
||||
radusergroup are not renamed automatically.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field label="Alias (group name)" required>
|
||||
<Input value={alias} onChange={(e) => setAlias(e.target.value)} />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={busy || !alias.trim()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteVlanDialog({
|
||||
vlan,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
vlan: Vlan | null
|
||||
onClose: () => void
|
||||
onDeleted: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function confirm() {
|
||||
if (!vlan) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.vlans.remove(vlan.vlanid)
|
||||
toast.success(`VLAN ${vlan.vlanid} deleted`)
|
||||
onClose()
|
||||
onDeleted()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to delete VLAN')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!vlan} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete VLAN {vlan?.vlanid}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Removes all radgroupreply rows for <span className="font-medium">{vlan?.alias}</span>.
|
||||
Devices still assigned to this group will lose their reply attributes.
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
{label}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import path from 'node:path'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(import.meta.dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// Proxy API calls in dev so the browser talks to same-origin /api,
|
||||
// avoiding CORS. The API key is sent as a header, never in the URL.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_TARGET ?? 'http://10.0.1.235:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user