added multi user support, move api key to database
build-and-push / build (push) Failing after 35s

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