This commit is contained in:
@@ -16,8 +16,13 @@ FreeRADIUS schema and a web admin portal for it.
|
|||||||
| `backend/` | RESTful CRUD over FreeRADIUS tables | FastAPI, SQLAlchemy 2.0, PyMySQL, Pydantic v2 |
|
| `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 |
|
| `frontend/` | Admin UI for clients, devices and VLANs | React 19, Vite, TypeScript, Tailwind v4 |
|
||||||
|
|
||||||
Auth across both is a shared secret sent in the **`X-API-Key`** header — the
|
Auth is **per-user**: sign in with a username and password and the backend issues
|
||||||
frontend collects the key on a login screen and the backend validates it.
|
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.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -27,13 +32,13 @@ Runs both services with hot-reload — the frontend on Vite, the backend on
|
|||||||
`uvicorn --reload`, each with its source volume-mounted from the host.
|
`uvicorn --reload`, each with its source volume-mounted from the host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp backend/.env.example backend/.env # edit DB credentials + API_KEY
|
cp backend/.env.example backend/.env # edit DB credentials
|
||||||
docker compose up --build # frontend :5173, backend :8000
|
docker compose up --build # frontend :5173, backend :8000
|
||||||
```
|
```
|
||||||
|
|
||||||
The frontend proxies `/api/*` to the `backend` service over the compose
|
The frontend proxies `/api/*` to the `backend` service over the compose
|
||||||
network, so log in at http://localhost:5173 with the `API_KEY` from
|
network, so log in at http://localhost:5173 with **`admin` / `admin`** on first
|
||||||
`backend/.env`.
|
run, then change the password when prompted.
|
||||||
|
|
||||||
### Manual
|
### Manual
|
||||||
|
|
||||||
@@ -43,7 +48,7 @@ network, so log in at http://localhost:5173 with the `API_KEY` from
|
|||||||
cd backend
|
cd backend
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
venv/bin/pip install -r requirements.txt
|
venv/bin/pip install -r requirements.txt
|
||||||
cp .env.example .env # edit DB credentials + API_KEY
|
cp .env.example .env # edit DB credentials
|
||||||
venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -55,5 +60,6 @@ npm install
|
|||||||
VITE_API_TARGET=http://127.0.0.1:8000 npm run dev # http://localhost:5173
|
VITE_API_TARGET=http://127.0.0.1:8000 npm run dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Log in with the same value set as `API_KEY` in `backend/.env`. In dev, Vite
|
Log in with **`admin` / `admin`** on first run (you'll be prompted to change the
|
||||||
proxies `/api/*` to the backend so requests stay same-origin.
|
password). In dev, Vite proxies `/api/*` to the backend so requests stay
|
||||||
|
same-origin.
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ DB_PASSWORD=changeme
|
|||||||
DB_NAME=radius
|
DB_NAME=radius
|
||||||
|
|
||||||
# --- API ---
|
# --- API ---
|
||||||
# Shared secret required in the X-API-Key header on every request.
|
# No shared API secret anymore. Auth is per-user: the API seeds a default
|
||||||
API_KEY=change-this-to-a-long-random-string
|
# 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)
|
# Comma-separated CORS origins (use * only for trusted/dev networks)
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|||||||
+104
-13
@@ -19,16 +19,21 @@ The two `radadmin_*` tables hold only human labels — RADIUS never reads them.
|
|||||||
- **FastAPI** + **Uvicorn** (ASGI)
|
- **FastAPI** + **Uvicorn** (ASGI)
|
||||||
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
|
- **SQLAlchemy 2.0** ORM + **PyMySQL** driver
|
||||||
- **Pydantic v2** request/response validation
|
- **Pydantic v2** request/response validation
|
||||||
- Auth via a shared secret in the **`X-API-Key`** header
|
- Per-user auth: username/password **login sessions** (`Authorization: Bearer`)
|
||||||
|
plus admin-issued **`X-API-Key`** keys for programmatic access
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
venv/bin/pip install -r requirements.txt
|
venv/bin/pip install -r requirements.txt
|
||||||
cp .env.example .env # then edit credentials + API_KEY
|
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`
|
### `.env`
|
||||||
|
|
||||||
| Var | Meaning |
|
| Var | Meaning |
|
||||||
@@ -37,7 +42,6 @@ cp .env.example .env # then edit credentials + API_KEY
|
|||||||
| `DB_PORT` | MySQL port (default 3306) |
|
| `DB_PORT` | MySQL port (default 3306) |
|
||||||
| `DB_USER` / `DB_PASSWORD` | DB credentials |
|
| `DB_USER` / `DB_PASSWORD` | DB credentials |
|
||||||
| `DB_NAME` | Database name (`radius`) |
|
| `DB_NAME` | Database name (`radius`) |
|
||||||
| `API_KEY` | Shared secret required in `X-API-Key` |
|
|
||||||
| `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) |
|
| `CORS_ORIGINS` | Comma-separated allowed origins (`*` for dev) |
|
||||||
| `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps |
|
| `DEFAULT_LIMIT` / `MAX_LIMIT` | List pagination caps |
|
||||||
|
|
||||||
@@ -58,13 +62,63 @@ venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
Every resource endpoint requires the header:
|
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:
|
||||||
|
|
||||||
```
|
```
|
||||||
X-API-Key: <your API_KEY>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Missing/wrong key → `401`. `/`, `/health`, and `/docs` are open.
|
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
|
## UI integration guide
|
||||||
|
|
||||||
@@ -73,7 +127,9 @@ For a management portal the primary resources are **Clients** (`/client`),
|
|||||||
raw-table access.
|
raw-table access.
|
||||||
|
|
||||||
**Base URL (staging):** `http://10.0.1.235:8000`
|
**Base URL (staging):** `http://10.0.1.235:8000`
|
||||||
**Every request:** header `X-API-Key: <API_KEY>` (except `/health`).
|
**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`.
|
**All bodies:** JSON with `Content-Type: application/json`.
|
||||||
|
|
||||||
### Response shapes
|
### Response shapes
|
||||||
@@ -254,10 +310,39 @@ standalone **`radadmin_devices`** table (keyed by AP MAC), which FreeRADIUS neve
|
|||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
Replace host/key to match your deployment.
|
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
|
```bash
|
||||||
# List customers
|
# 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 \
|
curl http://10.0.1.235:8000/customers?limit=50 \
|
||||||
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
-H "X-API-Key: staging-dev-key-change-me-7f3a9c1e5b" -s | jq
|
||||||
|
|
||||||
@@ -349,7 +434,8 @@ curl http://10.0.1.235:8000/health -s | jq
|
|||||||
| 201 | Created |
|
| 201 | Created |
|
||||||
| 204 | Deleted (no content) |
|
| 204 | Deleted (no content) |
|
||||||
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
|
| 400 | Bad request (e.g. referenced group/VLAN doesn't exist) |
|
||||||
| 401 | Missing/invalid `X-API-Key` |
|
| 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 |
|
| 404 | Row / client / device / VLAN not found |
|
||||||
| 409 | Duplicate / integrity conflict |
|
| 409 | Duplicate / integrity conflict |
|
||||||
| 422 | Request body failed validation |
|
| 422 | Request body failed validation |
|
||||||
@@ -358,16 +444,21 @@ curl http://10.0.1.235:8000/health -s | jq
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
main.py FastAPI app, router wiring, auth + CORS
|
main.py FastAPI app, router wiring, activity-log middleware, CORS
|
||||||
config.py env-driven settings (pydantic-settings)
|
config.py env-driven settings (pydantic-settings)
|
||||||
database.py SQLAlchemy engine/session
|
database.py SQLAlchemy engine/session
|
||||||
auth.py X-API-Key dependency
|
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
|
errors.py APIError + JSON handler
|
||||||
crud.py generic list/get/create/update/delete helpers
|
crud.py generic list/get/create/update/delete helpers
|
||||||
pagination.py Page envelope + limit/offset dependency
|
pagination.py Page envelope + limit/offset dependency
|
||||||
models.py SQLAlchemy models (incl. radadmin_clients, radadmin_devices)
|
models.py SQLAlchemy models (incl. radadmin_admins/sessions/api_keys/logs)
|
||||||
schemas.py Pydantic request/response models
|
schemas.py Pydantic request/response models
|
||||||
routers/ one module per resource
|
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
|
client.py Clients — MAC view over radcheck+radusergroup+customers
|
||||||
device.py Devices — AP MACs from radacct + radadmin_devices alias
|
device.py Devices — AP MACs from radacct + radadmin_devices alias
|
||||||
vlan.py VLANs — view over radgroupreply
|
vlan.py VLANs — view over radgroupreply
|
||||||
|
|||||||
+159
-7
@@ -1,15 +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
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import Security
|
from fastapi import Depends, Request, Security
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader, HTTPBearer
|
||||||
|
from fastapi.security.http import HTTPAuthorizationCredentials
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
from .config import get_settings
|
from .database import get_db
|
||||||
from .errors import APIError
|
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)
|
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
|
# Password + key hashing (PBKDF2-HMAC-SHA256, stdlib only — no native deps)
|
||||||
if not api_key or not secrets.compare_digest(api_key, expected):
|
# ---------------------------------------------------------------------------
|
||||||
raise APIError(status_code=401, detail="Invalid or missing API key")
|
def hash_password(password: str) -> str:
|
||||||
|
"""Return ``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``."""
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _PBKDF2_ITERATIONS)
|
||||||
|
return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${dk.hex()}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
try:
|
||||||
|
algo, iterations, salt_hex, hash_hex = stored.split("$")
|
||||||
|
if algo != "pbkdf2_sha256":
|
||||||
|
return False
|
||||||
|
dk = hashlib.pbkdf2_hmac(
|
||||||
|
"sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(dk.hex(), hash_hex)
|
||||||
|
|
||||||
|
|
||||||
|
# API keys reuse the same PBKDF2 scheme; the raw key is only shown once.
|
||||||
|
hash_api_key = hash_password
|
||||||
|
verify_api_key = verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""A 64-char hex session token (fits ``radadmin_sessions.token CHAR(64)``)."""
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_api_key() -> str:
|
||||||
|
"""A user-facing API key, e.g. ``rak_<40 hex chars>``."""
|
||||||
|
return "rak_" + secrets.token_hex(20)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sessions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def create_session(db: DbSession, admin: Admin) -> str:
|
||||||
|
token = generate_token()
|
||||||
|
db.add(Session(token=token, admin_id=admin.id, expires_at=datetime.utcnow() + SESSION_TTL))
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_from_bearer(db: DbSession, token: str) -> Admin | None:
|
||||||
|
session = db.get(Session, token)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
if session.expires_at < datetime.utcnow():
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
return db.get(Admin, session.admin_id)
|
||||||
|
|
||||||
|
|
||||||
|
# A synthetic principal for API keys whose creator no longer exists. It can act
|
||||||
|
# on resources but is not a real DB user, so it can't manage users/keys/logs.
|
||||||
|
_SYSTEM_ADMIN = Admin(id=0, username="api-key", is_admin=False, must_change_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_from_api_key(db: DbSession, raw_key: str) -> Admin | None:
|
||||||
|
prefix = raw_key[:8]
|
||||||
|
rows = (
|
||||||
|
db.execute(select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.revoked.is_(False)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if verify_api_key(raw_key, row.key_hash):
|
||||||
|
row.last_used_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
creator = db.execute(
|
||||||
|
select(Admin).where(Admin.username == row.created_by)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return creator or _SYSTEM_ADMIN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependencies
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def get_current_admin(
|
||||||
|
request: Request,
|
||||||
|
bearer: HTTPAuthorizationCredentials | None = Security(bearer_scheme),
|
||||||
|
api_key: str | None = Security(api_key_header),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
) -> Admin:
|
||||||
|
"""Resolve the caller from a Bearer session token or an X-API-Key.
|
||||||
|
|
||||||
|
Stashes the resolved username on ``request.state`` so the activity-log
|
||||||
|
middleware can attribute the request without re-parsing credentials.
|
||||||
|
"""
|
||||||
|
admin: Admin | None = None
|
||||||
|
if bearer and bearer.credentials:
|
||||||
|
admin = _admin_from_bearer(db, bearer.credentials)
|
||||||
|
if admin is None and api_key:
|
||||||
|
admin = _admin_from_api_key(db, api_key)
|
||||||
|
if admin is None:
|
||||||
|
raise APIError(status_code=401, detail="Invalid or missing credentials")
|
||||||
|
request.state.username = admin.username
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(admin: Admin = Depends(get_current_admin)) -> Admin:
|
||||||
|
"""Guard for endpoints only full admins may call."""
|
||||||
|
if not admin.is_admin:
|
||||||
|
raise APIError(status_code=403, detail="Administrator privileges required")
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Activity log helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def record_log(
|
||||||
|
db: DbSession,
|
||||||
|
*,
|
||||||
|
username: str | None,
|
||||||
|
action: str,
|
||||||
|
detail: str | None = None,
|
||||||
|
request: Request | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Insert one activity-log row. Never raises into the caller."""
|
||||||
|
ip = None
|
||||||
|
if request is not None and request.client is not None:
|
||||||
|
ip = request.client.host
|
||||||
|
try:
|
||||||
|
db.add(LogEntry(username=username, action=action, detail=detail, ip_address=ip))
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""First-run seeding. Creates the default ``admin`` / ``admin`` account when
|
||||||
|
``radadmin_admins`` is empty, forcing a password change on first login.
|
||||||
|
Idempotent: once any admin exists this does nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
from .database import SessionLocal
|
||||||
|
from .models import Admin
|
||||||
|
|
||||||
|
log = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
DEFAULT_USERNAME = "admin"
|
||||||
|
DEFAULT_PASSWORD = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_default_admin() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
count = db.execute(select(func.count()).select_from(Admin)).scalar_one()
|
||||||
|
if count:
|
||||||
|
return
|
||||||
|
db.add(
|
||||||
|
Admin(
|
||||||
|
username=DEFAULT_USERNAME,
|
||||||
|
password_hash=auth.hash_password(DEFAULT_PASSWORD),
|
||||||
|
is_admin=True,
|
||||||
|
must_change_password=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
log.warning(
|
||||||
|
"Seeded default admin account '%s' / '%s' — change this password on first login.",
|
||||||
|
DEFAULT_USERNAME,
|
||||||
|
DEFAULT_PASSWORD,
|
||||||
|
)
|
||||||
|
except Exception as exc: # never block startup on a seeding hiccup
|
||||||
|
db.rollback()
|
||||||
|
log.error("Could not seed default admin: %s", exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -12,7 +12,6 @@ class Settings(BaseSettings):
|
|||||||
db_password: str = ""
|
db_password: str = ""
|
||||||
db_name: str = "radius"
|
db_name: str = "radius"
|
||||||
|
|
||||||
api_key: str = "change-me"
|
|
||||||
cors_origins: str = "*"
|
cors_origins: str = "*"
|
||||||
default_limit: int = 50
|
default_limit: int = 50
|
||||||
max_limit: int = 500
|
max_limit: int = 500
|
||||||
|
|||||||
+60
-8
@@ -1,15 +1,21 @@
|
|||||||
from fastapi import Depends, FastAPI
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from .auth import require_api_key
|
from .auth import get_current_admin, record_log
|
||||||
|
from .bootstrap import seed_default_admin
|
||||||
from .config import get_settings
|
from .config import get_settings
|
||||||
from .database import engine
|
from .database import SessionLocal, engine
|
||||||
from .errors import APIError, api_error_handler
|
from .errors import APIError, api_error_handler
|
||||||
from .routers import (
|
from .routers import (
|
||||||
|
apikeys,
|
||||||
|
auth,
|
||||||
client,
|
client,
|
||||||
customers,
|
customers,
|
||||||
device,
|
device,
|
||||||
|
logs,
|
||||||
nas,
|
nas,
|
||||||
nasreload,
|
nasreload,
|
||||||
radacct,
|
radacct,
|
||||||
@@ -19,16 +25,26 @@ from .routers import (
|
|||||||
radpostauth,
|
radpostauth,
|
||||||
radreply,
|
radreply,
|
||||||
radusergroup,
|
radusergroup,
|
||||||
|
users,
|
||||||
vlan,
|
vlan,
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Seed the default admin/admin account on first run.
|
||||||
|
seed_default_admin()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FreeRADIUS REST API",
|
title="FreeRADIUS REST API",
|
||||||
description="RESTful CRUD over the FreeRADIUS SQL schema. "
|
description="RESTful CRUD over the FreeRADIUS SQL schema. Authenticate with a "
|
||||||
"All endpoints require the `X-API-Key` header.",
|
"login session (`Authorization: Bearer`) or an admin-issued `X-API-Key`.",
|
||||||
version="1.0.0",
|
version="2.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -42,6 +58,35 @@ app.add_middleware(
|
|||||||
app.add_exception_handler(APIError, api_error_handler)
|
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 ----
|
# ---- unauthenticated meta endpoints ----
|
||||||
@app.get("/", tags=["meta"])
|
@app.get("/", tags=["meta"])
|
||||||
def root():
|
def root():
|
||||||
@@ -59,8 +104,15 @@ def health():
|
|||||||
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
|
return {"status": "ok" if db_ok else "degraded", "database": "up" if db_ok else "down"}
|
||||||
|
|
||||||
|
|
||||||
# ---- authenticated resource routers ----
|
# ---- auth + management routers (guard themselves per-endpoint) ----
|
||||||
protected = [Depends(require_api_key)]
|
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 (
|
for module in (
|
||||||
customers,
|
customers,
|
||||||
nas,
|
nas,
|
||||||
|
|||||||
+61
-1
@@ -1,11 +1,71 @@
|
|||||||
from datetime import datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from .database import Base
|
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):
|
class Customer(Base):
|
||||||
__tablename__ = "customers"
|
__tablename__ = "customers"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Programmatic API keys. Only admins may create, list, or revoke keys.
|
||||||
|
|
||||||
|
A key authenticates via the ``X-API-Key`` header and acts as the admin who
|
||||||
|
created it. The raw key is returned exactly once, at creation; afterwards only
|
||||||
|
its prefix and hash are stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from .. import auth
|
||||||
|
from ..auth import require_admin
|
||||||
|
from ..database import get_db
|
||||||
|
from ..errors import APIError
|
||||||
|
from ..models import Admin, ApiKey
|
||||||
|
from ..schemas import ApiKeyCreate, ApiKeyCreated, ApiKeyOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/apikeys", tags=["apikeys"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ApiKeyOut])
|
||||||
|
def list_api_keys(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||||
|
return db.execute(select(ApiKey).order_by(ApiKey.id.desc())).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiKeyCreated, status_code=201)
|
||||||
|
def create_api_key(
|
||||||
|
payload: ApiKeyCreate,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
raw = auth.generate_api_key()
|
||||||
|
key = ApiKey(
|
||||||
|
name=payload.name,
|
||||||
|
key_prefix=raw[:8],
|
||||||
|
key_hash=auth.hash_api_key(raw),
|
||||||
|
created_by=admin.username,
|
||||||
|
)
|
||||||
|
db.add(key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(key)
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="create_api_key",
|
||||||
|
detail=f"Created API key '{key.name}' ({key.key_prefix}…)",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return ApiKeyCreated(**ApiKeyOut.model_validate(key).model_dump(), key=raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{key_id}", status_code=204)
|
||||||
|
def revoke_api_key(
|
||||||
|
key_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
key = db.get(ApiKey, key_id)
|
||||||
|
if key is None:
|
||||||
|
raise APIError(status_code=404, detail=f"API key '{key_id}' not found")
|
||||||
|
key.revoked = True
|
||||||
|
db.commit()
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="revoke_api_key",
|
||||||
|
detail=f"Revoked API key '{key.name}' ({key.key_prefix}…)",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Login, logout, current-user and self-service password change.
|
||||||
|
|
||||||
|
``POST /auth/login`` is the only unauthenticated endpoint here; the rest resolve
|
||||||
|
the caller from their Bearer session token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Security
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from .. import auth
|
||||||
|
from ..auth import bearer_scheme, get_current_admin
|
||||||
|
from ..database import get_db
|
||||||
|
from ..errors import APIError
|
||||||
|
from ..models import Admin, Session
|
||||||
|
from ..schemas import (
|
||||||
|
ChangePasswordRequest,
|
||||||
|
CurrentUser,
|
||||||
|
LoginRequest,
|
||||||
|
LoginResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=LoginResponse)
|
||||||
|
def login(payload: LoginRequest, request: Request, db: DbSession = Depends(get_db)):
|
||||||
|
admin = db.execute(
|
||||||
|
select(Admin).where(Admin.username == payload.username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if admin is None or not auth.verify_password(payload.password, admin.password_hash):
|
||||||
|
# Generic message so we don't leak which usernames exist.
|
||||||
|
raise APIError(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
token = auth.create_session(db, admin)
|
||||||
|
auth.record_log(
|
||||||
|
db, username=admin.username, action="login", detail="Signed in", request=request
|
||||||
|
)
|
||||||
|
return LoginResponse(
|
||||||
|
token=token,
|
||||||
|
username=admin.username,
|
||||||
|
is_admin=admin.is_admin,
|
||||||
|
must_change_password=admin.must_change_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=204)
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
creds=Security(bearer_scheme),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if creds and creds.credentials:
|
||||||
|
session = db.get(Session, creds.credentials)
|
||||||
|
if session is not None:
|
||||||
|
db.delete(session)
|
||||||
|
db.commit()
|
||||||
|
auth.record_log(db, username=admin.username, action="logout", detail="Signed out", request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=CurrentUser)
|
||||||
|
def me(admin: Admin = Depends(get_current_admin)):
|
||||||
|
return admin
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=204)
|
||||||
|
def change_password(
|
||||||
|
payload: ChangePasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(get_current_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Change your OWN password. Any authenticated user may do this.
|
||||||
|
|
||||||
|
When the account is flagged ``must_change_password`` (a forced first-login or
|
||||||
|
post-reset change) the current password is not required — the user just
|
||||||
|
authenticated with it. A normal self-service change still verifies it.
|
||||||
|
"""
|
||||||
|
if not admin.must_change_password:
|
||||||
|
if not payload.current_password or not auth.verify_password(
|
||||||
|
payload.current_password, admin.password_hash
|
||||||
|
):
|
||||||
|
raise APIError(status_code=400, detail="Current password is incorrect")
|
||||||
|
admin.password_hash = auth.hash_password(payload.new_password)
|
||||||
|
admin.must_change_password = False
|
||||||
|
db.commit()
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="change_password",
|
||||||
|
detail="Changed own password",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Read-only activity log. Admin-only: shows every state-changing action and
|
||||||
|
auth event, filterable by acting user."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from ..auth import require_admin
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Admin, LogEntry
|
||||||
|
from ..pagination import Page, PageParams
|
||||||
|
from ..schemas import LogOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=Page[LogOut])
|
||||||
|
def list_logs(
|
||||||
|
page: PageParams = Depends(),
|
||||||
|
username: str | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
_: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(LogEntry)
|
||||||
|
count_stmt = select(func.count()).select_from(LogEntry)
|
||||||
|
if username:
|
||||||
|
stmt = stmt.where(LogEntry.username == username)
|
||||||
|
count_stmt = count_stmt.where(LogEntry.username == username)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(LogEntry.action == action)
|
||||||
|
count_stmt = count_stmt.where(LogEntry.action == action)
|
||||||
|
|
||||||
|
total = int(db.execute(count_stmt).scalar_one())
|
||||||
|
rows = db.execute(
|
||||||
|
stmt.order_by(LogEntry.id.desc()).limit(page.limit).offset(page.offset)
|
||||||
|
).scalars().all()
|
||||||
|
return Page(total=total, limit=page.limit, offset=page.offset, items=rows)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Admin-portal user management. Every endpoint requires an admin — regular
|
||||||
|
users cannot create, delete, or reset the password of any account (they can
|
||||||
|
only change their own password via ``/auth/change-password``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from .. import auth
|
||||||
|
from ..auth import require_admin
|
||||||
|
from ..database import get_db
|
||||||
|
from ..errors import APIError
|
||||||
|
from ..models import Admin, Session
|
||||||
|
from ..schemas import AdminCreate, AdminOut, ResetPasswordRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AdminOut])
|
||||||
|
def list_users(_: Admin = Depends(require_admin), db: DbSession = Depends(get_db)):
|
||||||
|
return db.execute(select(Admin).order_by(Admin.id)).scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=AdminOut, status_code=201)
|
||||||
|
def create_user(
|
||||||
|
payload: AdminCreate,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
exists = db.execute(
|
||||||
|
select(Admin.id).where(Admin.username == payload.username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if exists is not None:
|
||||||
|
raise APIError(status_code=409, detail=f"User '{payload.username}' already exists")
|
||||||
|
|
||||||
|
user = Admin(
|
||||||
|
username=payload.username,
|
||||||
|
password_hash=auth.hash_password(payload.password),
|
||||||
|
is_admin=payload.is_admin,
|
||||||
|
must_change_password=payload.force_password_change,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="create_user",
|
||||||
|
detail=f"Created user '{user.username}'" + (" (admin)" if user.is_admin else ""),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=204)
|
||||||
|
def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if user_id == admin.id:
|
||||||
|
raise APIError(status_code=400, detail="You cannot delete your own account")
|
||||||
|
user = db.get(Admin, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||||
|
if user.is_admin:
|
||||||
|
admin_count = db.execute(
|
||||||
|
select(func.count()).select_from(Admin).where(Admin.is_admin.is_(True))
|
||||||
|
).scalar_one()
|
||||||
|
if admin_count <= 1:
|
||||||
|
raise APIError(status_code=400, detail="Cannot delete the last administrator")
|
||||||
|
|
||||||
|
# Drop the user's sessions too so any live token stops working immediately.
|
||||||
|
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||||
|
db.delete(s)
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="delete_user",
|
||||||
|
detail=f"Deleted user '{user.username}'",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reset-password", status_code=204)
|
||||||
|
def reset_password(
|
||||||
|
user_id: int,
|
||||||
|
payload: ResetPasswordRequest,
|
||||||
|
request: Request,
|
||||||
|
admin: Admin = Depends(require_admin),
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Admin resets another user's password; they must change it on next login."""
|
||||||
|
user = db.get(Admin, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise APIError(status_code=404, detail=f"User '{user_id}' not found")
|
||||||
|
user.password_hash = auth.hash_password(payload.new_password)
|
||||||
|
user.must_change_password = payload.force_password_change
|
||||||
|
# Invalidate existing sessions so the old password/session can't be reused.
|
||||||
|
for s in db.execute(select(Session).where(Session.admin_id == user.id)).scalars().all():
|
||||||
|
db.delete(s)
|
||||||
|
db.commit()
|
||||||
|
auth.record_log(
|
||||||
|
db,
|
||||||
|
username=admin.username,
|
||||||
|
action="reset_password",
|
||||||
|
detail=f"Reset password for '{user.username}'",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
@@ -9,6 +9,84 @@ class ORMModel(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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 ----------
|
# ---------- customers ----------
|
||||||
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
|
# Human metadata (name/phone/alias) lives in radadmin_clients now — customers holds
|
||||||
# only the RADIUS-adjacent identity + billing status.
|
# only the RADIUS-adjacent identity + billing status.
|
||||||
|
|||||||
@@ -31,3 +31,83 @@ CREATE TABLE IF NOT EXISTS radadmin_devices (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_radadmin_devices_mac (mac_address)
|
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)
|
||||||
|
);
|
||||||
|
|||||||
+16
-9
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
|
Web UI for the [`radapi`](../radapi) FreeRADIUS REST API. Manage **clients**
|
||||||
(customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs**
|
(customer MACs), **devices** (NAS/AP boxes seen in accounting), and **VLANs**
|
||||||
without touching SQL. Auth is an **API key** entered on a login screen and stored in
|
without touching SQL. Admins also manage **users**, **API keys**, and view an
|
||||||
the browser (`localStorage`), sent as `X-API-Key` on every request.
|
**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
|
## Stack
|
||||||
|
|
||||||
@@ -29,15 +31,16 @@ nix-shell --run "npm run dev -- --host 0.0.0.0"
|
|||||||
### Talking to the API
|
### Talking to the API
|
||||||
|
|
||||||
In dev, Vite proxies `/api/*` to the backend so the browser makes same-origin
|
In dev, Vite proxies `/api/*` to the backend so the browser makes same-origin
|
||||||
requests (no CORS) and the key is only ever sent as a header. The target defaults
|
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:
|
to `http://10.0.1.235:8000`; override it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VITE_API_TARGET=http://192.168.1.21:8000 npm run dev
|
VITE_API_TARGET=http://192.168.1.21:8000 npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Log in with the API key configured in radapi's `.env` (`API_KEY`). A `401` from any
|
Log in with **`admin` / `admin`** on first run (you'll be prompted to change the
|
||||||
request clears the stored key and returns you to the login screen.
|
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
|
## Build
|
||||||
|
|
||||||
@@ -54,12 +57,16 @@ API (or set `VITE_API_BASE` to the API's absolute URL at build time).
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
main.tsx providers (router, auth, toaster)
|
main.tsx providers (router, auth, toaster)
|
||||||
App.tsx auth gate + nav + routes
|
App.tsx auth gate + forced-password-change + nav (role-gated) + routes
|
||||||
auth/auth.tsx API-key auth context (login/logout, 401 handling)
|
auth/auth.tsx session auth context (login/logout, current user, 401 handling)
|
||||||
lib/api.ts typed API client (client + device + vlan) + error handling
|
lib/api.ts typed API client (auth + users + apikeys + logs + client/device/vlan)
|
||||||
lib/utils.ts cn() helper
|
lib/utils.ts cn() helper
|
||||||
pages/
|
pages/
|
||||||
Login.tsx API-key login screen
|
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
|
Clients.tsx client list + add/edit/delete + CSV import/export dialogs
|
||||||
Devices.tsx NAS device list + editable alias
|
Devices.tsx NAS device list + editable alias
|
||||||
Vlans.tsx VLAN list + add/rename/delete dialogs
|
Vlans.tsx VLAN list + add/rename/delete dialogs
|
||||||
|
|||||||
+50
-6
@@ -1,11 +1,25 @@
|
|||||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { LogOut, Moon, Router, Sun, Users, Wifi } from 'lucide-react'
|
import {
|
||||||
|
KeyRound,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Router,
|
||||||
|
ScrollText,
|
||||||
|
Sun,
|
||||||
|
UserCog,
|
||||||
|
Users as UsersIcon,
|
||||||
|
Wifi,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useAuth } from '@/auth/auth'
|
import { useAuth } from '@/auth/auth'
|
||||||
import { useTheme } from '@/lib/theme'
|
import { useTheme } from '@/lib/theme'
|
||||||
import { Login } from '@/pages/Login'
|
import { Login } from '@/pages/Login'
|
||||||
import { Clients } from '@/pages/Clients'
|
import { Clients } from '@/pages/Clients'
|
||||||
import { Devices } from '@/pages/Devices'
|
import { Devices } from '@/pages/Devices'
|
||||||
import { Vlans } from '@/pages/Vlans'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -25,7 +39,7 @@ function ThemeToggle() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Nav() {
|
function Nav({ username, isAdmin }: { username: string; isAdmin: boolean }) {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
cn(
|
cn(
|
||||||
@@ -41,7 +55,7 @@ function Nav() {
|
|||||||
RADIUS Admin
|
RADIUS Admin
|
||||||
</div>
|
</div>
|
||||||
<NavLink to="/clients" className={linkClass}>
|
<NavLink to="/clients" className={linkClass}>
|
||||||
<Users className="h-4 w-4" /> Clients
|
<UsersIcon className="h-4 w-4" /> Clients
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/devices" className={linkClass}>
|
<NavLink to="/devices" className={linkClass}>
|
||||||
<Router className="h-4 w-4" /> Devices
|
<Router className="h-4 w-4" /> Devices
|
||||||
@@ -49,7 +63,23 @@ function Nav() {
|
|||||||
<NavLink to="/vlans" className={linkClass}>
|
<NavLink to="/vlans" className={linkClass}>
|
||||||
<Wifi className="h-4 w-4" /> VLANs
|
<Wifi className="h-4 w-4" /> VLANs
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<NavLink to="/users" className={linkClass}>
|
||||||
|
<UserCog className="h-4 w-4" /> Users
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/apikeys" className={linkClass}>
|
||||||
|
<KeyRound className="h-4 w-4" /> API Keys
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/logs" className={linkClass}>
|
||||||
|
<ScrollText className="h-4 w-4" /> Activity Log
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<NavLink to="/account" className={linkClass} title="Change password">
|
||||||
|
{username}
|
||||||
|
</NavLink>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
|
<Button variant="ghost" size="sm" className="text-muted-foreground" onClick={logout}>
|
||||||
<LogOut className="h-4 w-4" /> Sign out
|
<LogOut className="h-4 w-4" /> Sign out
|
||||||
@@ -61,18 +91,32 @@ function Nav() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { authenticated } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
||||||
if (!authenticated) return <Login />
|
if (!user) return <Login />
|
||||||
|
|
||||||
|
if (user.must_change_password) return <ChangePassword forced />
|
||||||
|
|
||||||
|
const admin = user.is_admin
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-muted/20">
|
<div className="min-h-screen bg-muted/20">
|
||||||
<Nav />
|
<Nav username={user.username} isAdmin={admin} />
|
||||||
<main className="mx-auto max-w-6xl px-4 py-6">
|
<main className="mx-auto max-w-6xl px-4 py-6">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/clients" element={<Clients />} />
|
<Route path="/clients" element={<Clients />} />
|
||||||
<Route path="/devices" element={<Devices />} />
|
<Route path="/devices" element={<Devices />} />
|
||||||
<Route path="/vlans" element={<Vlans />} />
|
<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 />} />
|
<Route path="*" element={<Navigate to="/clients" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+45
-17
@@ -2,49 +2,77 @@ import { createContext, useCallback, useContext, useEffect, useState, type React
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
clearApiKey,
|
clearToken,
|
||||||
getApiKey,
|
getToken,
|
||||||
setApiKey,
|
setToken,
|
||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
|
type CurrentUser,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
user: CurrentUser | null
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
login: (key: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
refresh: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthState | null>(null)
|
const AuthContext = createContext<AuthState | null>(null)
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [authenticated, setAuthenticated] = useState(() => !!getApiKey())
|
const [user, setUser] = useState<CurrentUser | null>(null)
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearApiKey()
|
api.auth.logout().catch(() => {}) // best effort; token is cleared regardless
|
||||||
setAuthenticated(false)
|
clearToken()
|
||||||
|
setUser(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Any 401 from the API layer forces a logout so the login screen reappears.
|
// Any 401 from the API layer forces a logout so the login screen reappears.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(logout)
|
setUnauthorizedHandler(() => {
|
||||||
}, [logout])
|
clearToken()
|
||||||
|
setUser(null)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(async (key: string) => {
|
// On mount, if a token exists, load the current user; drop the token if it's stale.
|
||||||
setApiKey(key)
|
useEffect(() => {
|
||||||
|
if (!getToken()) return
|
||||||
|
api.auth
|
||||||
|
.me()
|
||||||
|
.then(setUser)
|
||||||
|
.catch(() => {
|
||||||
|
clearToken()
|
||||||
|
setUser(null)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
await api.ping() // validates the key; throws ApiError(401) if wrong
|
const res = await api.auth.login(username, password)
|
||||||
setAuthenticated(true)
|
setToken(res.token)
|
||||||
|
// Fetch the full profile (including id) with the fresh token.
|
||||||
|
setUser(await api.auth.me())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearApiKey()
|
clearToken()
|
||||||
setAuthenticated(false)
|
setUser(null)
|
||||||
if (err instanceof ApiError && err.status === 401) {
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
throw new Error('That API key was rejected.')
|
throw new Error('Invalid username or password')
|
||||||
}
|
}
|
||||||
throw err instanceof Error ? err : new Error('Could not reach the API.')
|
throw err instanceof Error ? err : new Error('Could not reach the API.')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <AuthContext value={{ authenticated, login, logout }}>{children}</AuthContext>
|
const refresh = useCallback(async () => {
|
||||||
|
setUser(await api.auth.me())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext value={{ user, authenticated: !!user, login, logout, refresh }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth(): AuthState {
|
export function useAuth(): AuthState {
|
||||||
|
|||||||
+118
-14
@@ -3,20 +3,23 @@
|
|||||||
// makes same-origin requests and the API key travels only as a header.
|
// makes same-origin requests and the API key travels only as a header.
|
||||||
|
|
||||||
const BASE = import.meta.env.VITE_API_BASE ?? '/api'
|
const BASE = import.meta.env.VITE_API_BASE ?? '/api'
|
||||||
const KEY_STORAGE = 'radui.apiKey'
|
const TOKEN_STORAGE = 'radui.token'
|
||||||
|
const OLD_KEY_STORAGE = 'radui.apiKey'
|
||||||
|
|
||||||
// ---- API key storage ----
|
// ---- Session token storage ----
|
||||||
export function getApiKey(): string | null {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(KEY_STORAGE)
|
return localStorage.getItem(TOKEN_STORAGE)
|
||||||
}
|
}
|
||||||
export function setApiKey(key: string) {
|
export function setToken(token: string) {
|
||||||
localStorage.setItem(KEY_STORAGE, key)
|
localStorage.setItem(TOKEN_STORAGE, token)
|
||||||
}
|
}
|
||||||
export function clearApiKey() {
|
export function clearToken() {
|
||||||
localStorage.removeItem(KEY_STORAGE)
|
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 key is bad — notify the app to log out.
|
// A 401 anywhere means the stored token is bad/expired — notify the app to log out.
|
||||||
let onUnauthorized: (() => void) | null = null
|
let onUnauthorized: (() => void) | null = null
|
||||||
export function setUnauthorizedHandler(fn: () => void) {
|
export function setUnauthorizedHandler(fn: () => void) {
|
||||||
onUnauthorized = fn
|
onUnauthorized = fn
|
||||||
@@ -47,16 +50,16 @@ function messageFromDetail(detail: unknown, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const key = getApiKey()
|
const token = getToken()
|
||||||
const headers = new Headers(options.headers)
|
const headers = new Headers(options.headers)
|
||||||
if (key) headers.set('X-API-Key', key)
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
if (options.body) headers.set('Content-Type', 'application/json')
|
if (options.body) headers.set('Content-Type', 'application/json')
|
||||||
|
|
||||||
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
onUnauthorized?.()
|
onUnauthorized?.()
|
||||||
throw new ApiError(401, 'Invalid or missing API key')
|
throw new ApiError(401, 'Session expired or unauthorized')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T
|
if (res.status === 204) return undefined as T
|
||||||
@@ -150,10 +153,111 @@ export interface Page<T> {
|
|||||||
items: T[]
|
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 ----
|
// ---- Endpoints ----
|
||||||
export const api = {
|
export const api = {
|
||||||
// health check to validate the API key on login
|
auth: {
|
||||||
ping: () => request<unknown>('/vlan/'),
|
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: {
|
clients: {
|
||||||
list: (limit = 50, offset = 0) =>
|
list: (limit = 50, offset = 0) =>
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Ban, Check, Copy, Loader2, Plus, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api, ApiError, type ApiKeyCreated, type ApiKeyRow } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
function fmt(ts: string | null) {
|
||||||
|
return ts ? new Date(ts).toLocaleString() : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiKeys() {
|
||||||
|
const [keys, setKeys] = useState<ApiKeyRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [revoking, setRevoking] = useState<ApiKeyRow | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setKeys(await api.apikeys.list())
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load API keys')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">API keys</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{keys.length} keys</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus /> Create API key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Prefix</TableHead>
|
||||||
|
<TableHead>Created by</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Last used</TableHead>
|
||||||
|
<TableHead className="w-24">Status</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && keys.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||||
|
No API keys yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
keys.map((k) => (
|
||||||
|
<TableRow key={k.id}>
|
||||||
|
<TableCell className="font-medium">{k.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{k.key_prefix}…</TableCell>
|
||||||
|
<TableCell>{k.created_by}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{fmt(k.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{fmt(k.last_used_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{k.revoked ? (
|
||||||
|
<Badge variant="destructive">Revoked</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="success">Active</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{!k.revoked && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setRevoking(k)}
|
||||||
|
title="Revoke"
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateApiKeyDialog open={createOpen} onOpenChange={setCreateOpen} onSaved={load} />
|
||||||
|
<RevokeApiKeyDialog apikey={revoking} onClose={() => setRevoking(null)} onRevoked={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateApiKeyDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [created, setCreated] = useState<ApiKeyCreated | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName('')
|
||||||
|
setCreated(null)
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await api.apikeys.create(name.trim())
|
||||||
|
setCreated(res)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create API key')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!created) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(created.key)
|
||||||
|
setCopied(true)
|
||||||
|
toast.success('API key copied to clipboard')
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
{created ? (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>API key created</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Copy this key now. For security it will not be shown again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{created.name}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2 font-mono text-xs">
|
||||||
|
{created.key}
|
||||||
|
</code>
|
||||||
|
<Button variant="outline" size="icon" onClick={copy} title="Copy">
|
||||||
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create API key</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Generate a new API key for programmatic access.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
Name<span className="text-destructive"> *</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="billing-sync"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !name.trim()}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevokeApiKeyDialog({
|
||||||
|
apikey,
|
||||||
|
onClose,
|
||||||
|
onRevoked,
|
||||||
|
}: {
|
||||||
|
apikey: ApiKeyRow | null
|
||||||
|
onClose: () => void
|
||||||
|
onRevoked: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!apikey) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.apikeys.revoke(apikey.id)
|
||||||
|
toast.success(`API key ${apikey.name} revoked`)
|
||||||
|
onClose()
|
||||||
|
onRevoked()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to revoke API key')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!apikey} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Revoke API key?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<span className="font-medium">{apikey?.name}</span> will stop working immediately. This
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { KeyRound, Loader2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useAuth } from '@/auth/auth'
|
||||||
|
import { api, ApiError } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
|
||||||
|
const MIN_LENGTH = 6
|
||||||
|
|
||||||
|
export function ChangePassword({ forced = false }: { forced?: boolean }) {
|
||||||
|
const { refresh } = useAuth()
|
||||||
|
const [current, setCurrent] = useState('')
|
||||||
|
const [next, setNext] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const tooShort = next.length > 0 && next.length < MIN_LENGTH
|
||||||
|
const mismatch = confirm.length > 0 && next !== confirm
|
||||||
|
// On a forced first-login change the current password isn't asked for — the
|
||||||
|
// user just signed in with it — so it isn't part of validation.
|
||||||
|
const valid = (forced || current.length > 0) && next.length >= MIN_LENGTH && next === confirm
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!valid) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.auth.changePassword(next, forced ? undefined : current)
|
||||||
|
await refresh()
|
||||||
|
toast.success('Password changed')
|
||||||
|
setCurrent('')
|
||||||
|
setNext('')
|
||||||
|
setConfirm('')
|
||||||
|
} catch (err) {
|
||||||
|
const msg =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to change password'
|
||||||
|
setError(msg)
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
{!forced && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="current">Current password</Label>
|
||||||
|
<Input
|
||||||
|
id="current"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new">New password</Label>
|
||||||
|
<Input
|
||||||
|
id="new"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
{tooShort && (
|
||||||
|
<p className="text-xs text-destructive">Must be at least {MIN_LENGTH} characters.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm">Confirm new password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
{mismatch && <p className="text-xs text-destructive">Passwords do not match.</p>}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full" disabled={busy || !valid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{busy ? 'Saving…' : 'Change password'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (forced) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="mx-auto mb-2 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<KeyRound className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Change your password</CardTitle>
|
||||||
|
<CardDescription>You must change your password before continuing.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{form}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-sm">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-xl">Change password</CardTitle>
|
||||||
|
<CardDescription>Update the password for your account.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{form}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { KeyRound, Loader2 } from 'lucide-react'
|
import { LogIn, Loader2 } from 'lucide-react'
|
||||||
import { useAuth } from '@/auth/auth'
|
import { useAuth } from '@/auth/auth'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -14,17 +14,20 @@ import {
|
|||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const [key, setKey] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
const valid = username.trim() && password
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!key.trim()) return
|
if (!valid) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await login(key.trim())
|
await login(username.trim(), password)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Login failed')
|
setError(err instanceof Error ? err.message : 'Login failed')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,29 +40,37 @@ export function Login() {
|
|||||||
<Card className="w-full max-w-sm">
|
<Card className="w-full max-w-sm">
|
||||||
<CardHeader className="space-y-1 text-center">
|
<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">
|
<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" />
|
<LogIn className="h-5 w-5 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">RADIUS Admin</CardTitle>
|
<CardTitle className="text-xl">RADIUS Admin</CardTitle>
|
||||||
<CardDescription>Enter your API key to continue</CardDescription>
|
<CardDescription>Sign in to continue</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="apikey">API key</Label>
|
<Label htmlFor="username">Username</Label>
|
||||||
<Input
|
<Input
|
||||||
id="apikey"
|
id="username"
|
||||||
type="password"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="X-API-Key value"
|
value={username}
|
||||||
value={key}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onChange={(e) => setKey(e.target.value)}
|
autoComplete="username"
|
||||||
autoComplete="off"
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<Button type="submit" className="w-full" disabled={busy || !key.trim()}>
|
<Button type="submit" className="w-full" disabled={busy || !valid}>
|
||||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
{busy ? 'Verifying…' : 'Sign in'}
|
{busy ? 'Signing in…' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { api, ApiError, type LogRow } from '@/lib/api'
|
||||||
|
import { TablePagination, usePageSize } from '@/components/Pagination'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
|
||||||
|
function fmt(ts: string) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logs() {
|
||||||
|
const [items, setItems] = useState<LogRow[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
|
||||||
|
const [pageSize, setPageSize] = usePageSize()
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.logs.list(pageSize, (page - 1) * pageSize, {
|
||||||
|
username: username.trim() || undefined,
|
||||||
|
})
|
||||||
|
setItems(res.items)
|
||||||
|
setTotal(res.total)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load activity log')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [page, pageSize, username])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
useEffect(() => setPage(1), [pageSize, username])
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > pageCount) setPage(pageCount)
|
||||||
|
}, [page, pageCount])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Activity log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{total} events</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter by username…"
|
||||||
|
className="w-56"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-48">Time</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Detail</TableHead>
|
||||||
|
<TableHead className="w-36">IP address</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||||
|
No activity recorded.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="text-muted-foreground">{fmt(row.created_at)}</TableCell>
|
||||||
|
<TableCell className="font-medium">{row.username}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{row.action}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{row.detail ?? '—'}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.ip_address ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<TablePagination
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
total={total}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import { KeyRound, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useAuth } from '@/auth/auth'
|
||||||
|
import { api, ApiError, type AdminUser } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
function fmt(ts: string) {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Users() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [addOpen, setAddOpen] = useState(false)
|
||||||
|
const [resetting, setResetting] = useState<AdminUser | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<AdminUser | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
setUsers(await api.users.list())
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof ApiError && err.status === 401))
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to load users')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Users</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{users.length} accounts</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setAddOpen(true)}>
|
||||||
|
<Plus /> Add user
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-background">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead className="w-28">Role</TableHead>
|
||||||
|
<TableHead className="w-40">Password</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-24 text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
|
||||||
|
No users.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((u) => (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell className="font-medium">{u.username}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={u.is_admin ? 'default' : 'secondary'}>
|
||||||
|
{u.is_admin ? 'Admin' : 'User'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{u.must_change_password ? (
|
||||||
|
<Badge variant="warning">Must change</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{fmt(u.created_at)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setResetting(u)}
|
||||||
|
title="Reset password"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{u.username !== user?.username && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => setDeleting(u)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddUserDialog open={addOpen} onOpenChange={setAddOpen} onSaved={load} />
|
||||||
|
<ResetPasswordDialog user={resetting} onClose={() => setResetting(null)} onSaved={load} />
|
||||||
|
<DeleteUserDialog user={deleting} onClose={() => setDeleting(null)} onDeleted={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddUserDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (o: boolean) => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
const [forcePwChange, setForcePwChange] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setUsername('')
|
||||||
|
setPassword('')
|
||||||
|
setIsAdmin(false)
|
||||||
|
setForcePwChange(true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const valid = username.trim() && password.length >= 6
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!valid) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.users.create({
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
is_admin: isAdmin,
|
||||||
|
force_password_change: forcePwChange,
|
||||||
|
})
|
||||||
|
toast.success(`User ${username.trim()} created`)
|
||||||
|
onOpenChange(false)
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to create user')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add user</DialogTitle>
|
||||||
|
<DialogDescription>Create a new admin-portal account.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="Username" required>
|
||||||
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Password (min 6 chars)" required>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
checked={isAdmin}
|
||||||
|
onChange={(e) => setIsAdmin(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Administrator
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
checked={forcePwChange}
|
||||||
|
onChange={(e) => setForcePwChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Force password change on first login
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !valid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPasswordDialog({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
user: AdminUser | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
}) {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [forcePwChange, setForcePwChange] = useState(true)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setPassword('')
|
||||||
|
setForcePwChange(true)
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const valid = password.length >= 6
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!user || !valid) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.users.resetPassword(user.id, password, forcePwChange)
|
||||||
|
toast.success(`Password reset for ${user.username}`)
|
||||||
|
onClose()
|
||||||
|
onSaved()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to reset password')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reset password</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Sets a new password for <span className="font-medium">{user?.username}</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Field label="New password (min 6 chars)" required>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-input accent-primary"
|
||||||
|
checked={forcePwChange}
|
||||||
|
onChange={(e) => setForcePwChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Force password change on next login
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !valid}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteUserDialog({
|
||||||
|
user,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
user: AdminUser | null
|
||||||
|
onClose: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
}) {
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!user) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.users.remove(user.id)
|
||||||
|
toast.success(`User ${user.username} deleted`)
|
||||||
|
onClose()
|
||||||
|
onDeleted()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to delete user')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete user?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This permanently removes <span className="font-medium">{user?.username}</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
required?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user