Compare commits

..
29 Commits
Author SHA1 Message Date
shihaam e3b46c84a9 update docs
build-and-push / build (push) Failing after 19s
2026-08-01 14:03:54 +05:00
shihaam 46f130150a softdelete users and migration script 2026-08-01 14:02:44 +05:00
shihaam 4a7de76155 new ui + search clients
build-and-push / build (push) Failing after 34s
2026-08-01 13:34:07 +05:00
shihaam 2957347583 new ui + search clients
build-and-push / build (push) Failing after 54s
2026-08-01 13:31:46 +05:00
shihaam 66073c7891 added multi user support, move api key to database
build-and-push / build (push) Failing after 35s
2026-08-01 13:16:54 +05:00
shihaam a7c5fe739a must add vlan first
build-and-push / build (push) Failing after 27s
2026-08-01 04:59:09 +05:00
shihaam 45a35c0b01 update deplpy docs
build-and-push / build (push) Failing after 22s
2026-08-01 04:43:15 +05:00
shihaam f4c1872bfc update deplpy docs
build-and-push / build (push) Failing after 17s
2026-08-01 04:41:50 +05:00
shihaam 136bd5a199 update deplpy docs
build-and-push / build (push) Failing after 34s
2026-08-01 04:40:08 +05:00
shihaam 251efd4d0c update deploy docs
build-and-push / build (push) Failing after 24s
2026-08-01 03:15:32 +05:00
shihaam 4f2f953f34 move page amount selection to other side
build-and-push / build (push) Failing after 28s
2026-08-01 03:06:47 +05:00
shihaam 1af12891a7 paginate views
build-and-push / build (push) Failing after 34s
2026-08-01 03:02:35 +05:00
shihaam 141d380abf multiline for SSIDs and IPs
build-and-push / build (push) Failing after 29s
2026-08-01 02:49:02 +05:00
shihaam db140912f5 refactor code, new db tables, show AP info on UI
build-and-push / build (push) Failing after 34s
2026-08-01 02:44:50 +05:00
shihaam a6d4fa1f6d import/export devices
build-and-push / build (push) Failing after 17s
2026-08-01 01:47:42 +05:00
shihaam 6fe7c69adb clean up
build-and-push / build (push) Failing after 32s
2026-08-01 00:51:49 +05:00
shihaam cd99049a92 clean up
build-and-push / build (push) Failing after 40s
2026-08-01 00:51:08 +05:00
shihaam d406e56856 run on docker
build-and-push / build (push) Failing after 3m42s
2026-08-01 00:30:38 +05:00
shihaam 2f66c04d1b build backend and auto build and push everything
build-and-push / build (push) Canceled after 0s
2026-08-01 00:27:38 +05:00
shihaam 533faabab9 update docker for mono repo 2026-08-01 00:12:32 +05:00
shihaam ab75dd33f1 update docker for mono repo 2026-08-01 00:12:10 +05:00
shihaam 29900e5625 update docs 2026-08-01 00:01:35 +05:00
shihaam 010d98df1c Add 'frontend/' from commit '63381d9f2a4829e09d9ba6f29608bbaa2eb38601'
git-subtree-dir: frontend
git-subtree-mainline: 79bc04aaa7
git-subtree-split: 63381d9f2a
2026-07-31 23:53:50 +05:00
shihaam 79bc04aaa7 restructure: move backend into backend/ 2026-07-31 23:53:50 +05:00
shihaam 63381d9f2a add docker dev build 2026-07-31 23:50:11 +05:00
shihaam c450ac2903 add docker prod build 2026-07-31 23:45:42 +05:00
shihaam 428536fb1b add number validation 2026-07-31 23:27:26 +05:00
shihaam 46f88ca04c add dark theme 2026-07-31 23:15:33 +05:00
shihaam e90199803d init ui 2026-07-31 23:12:07 +05:00
93 changed files with 9324 additions and 612 deletions
+6
View File
@@ -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
+92
View File
@@ -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
```
+7
View File
@@ -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"]
+15
View File
@@ -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
+13
View File
@@ -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
+19
View File
@@ -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;
}
}
+29
View File
@@ -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
View File
@@ -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
*~
+49 -287
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-139
View File
@@ -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()
+3 -2
View File
@@ -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=*
+8
View File
@@ -0,0 +1,8 @@
venv/*
.env
tmp/
.build/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+472
View File
@@ -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, ...
```
+167
View File
@@ -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()
+44
View File
@@ -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()
-1
View File
@@ -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
View File
+131
View File
@@ -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)
+98 -2
View File
@@ -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()
)
+72
View File
@@ -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,
)
+94
View File
@@ -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,
)
+303
View File
@@ -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()
+96
View File
@@ -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)
+38
View File
@@ -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)
+114
View File
@@ -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,
)
+131 -11
View File
@@ -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)
+10
View File
@@ -0,0 +1,10 @@
services:
backend:
build:
context: .build/dev
volumes:
- ./:/app
ports:
- 8000:8000
env_file:
- .env
+135
View File
@@ -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())
+143
View File
@@ -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)
);
+3
View File
@@ -0,0 +1,3 @@
include:
- frontend/compose.yml
- backend/compose.yml
-71
View File
@@ -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
+5
View File
@@ -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
+11
View File
@@ -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
+27
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+90
View File
@@ -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`).
+11
View File
@@ -0,0 +1,11 @@
services:
frontend:
build:
context: .build/dev
dockerfile: Dockerfile
volumes:
- ./:/var/www/html/
ports:
- 5173:5173
env_file:
- .env
+27
View File
@@ -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>
+2921
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -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

+11
View File
@@ -0,0 +1,11 @@
{ pkgs ? import <nixpkgs> { } }:
pkgs.mkShell {
packages = [
pkgs.nodejs_22
];
shellHook = ''
echo "radui dev shell — node $(node --version), npm $(npm --version)"
'';
}
+213
View File
@@ -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>
)
}
+82
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
}
+32
View File
@@ -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 }
+48
View File
@@ -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 }
+33
View File
@@ -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 }
+92
View File
@@ -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,
}
+21
View File
@@ -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 }
+20
View File
@@ -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 }
+77
View File
@@ -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 }
+17
View File
@@ -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 }
+66
View File
@@ -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 }
+85
View File
@@ -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;
}
}
+309
View File
@@ -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' }),
},
}
+23
View File
@@ -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
}
+38
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+21
View File
@@ -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>,
)
+292
View File
@@ -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>
)
}
+133
View File
@@ -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>
)
}
+580
View File
@@ -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>
)
}
+214
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+126
View File
@@ -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>
)
}
+394
View File
@@ -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>
)
}
+339
View File
@@ -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>
)
}
+29
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+26
View File
@@ -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/, ''),
},
},
},
})