diff --git a/README.md b/README.md index d8e92b9..89eab97 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/.env.example b/backend/.env.example index 689bafc..74c6a10 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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=* diff --git a/backend/README.md b/backend/README.md index edb4b82..c89fa09 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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: -``` +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 + ``` + +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: + ``` + +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: ` (except `/health`). +**Every request:** an auth header — `Authorization: Bearer ` from +`POST /auth/login`, or `X-API-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 diff --git a/backend/app/auth.py b/backend/app/auth.py index 0106f8d..fa096c3 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 ``, issued by ``POST /auth/login``) or with a +programmatic API key (``X-API-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$$$``.""" + 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() diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py new file mode 100644 index 0000000..97f3a34 --- /dev/null +++ b/backend/app/bootstrap.py @@ -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() diff --git a/backend/app/config.py b/backend/app/config.py index 6c66df8..053c57e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 5ec236f..d41c682 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/models.py b/backend/app/models.py index 198aa6c..e9dba99 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/routers/apikeys.py b/backend/app/routers/apikeys.py new file mode 100644 index 0000000..28ba14f --- /dev/null +++ b/backend/app/routers/apikeys.py @@ -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, + ) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..827be65 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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, + ) diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..f3ff1c6 --- /dev/null +++ b/backend/app/routers/logs.py @@ -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) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..46eff2a --- /dev/null +++ b/backend/app/routers/users.py @@ -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, + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 41a4ca7..2ae97f7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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. diff --git a/backend/radadmin_schema.sql b/backend/radadmin_schema.sql index a1bf275..7d88b1c 100644 --- a/backend/radadmin_schema.sql +++ b/backend/radadmin_schema.sql @@ -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$$$". 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 ". 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) +); diff --git a/frontend/README.md b/frontend/README.md index 4da4ca2..94cc27e 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51a4c49..8aab09c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 - Clients + Clients Devices @@ -49,7 +63,23 @@ function Nav() { VLANs + {isAdmin && ( + <> + + Users + + + API Keys + + + Activity Log + + + )}
+ + {username} + + +
+ + +
+ + + + Name + Prefix + Created by + Created + Last used + Status + Actions + + + + {loading && keys.length === 0 ? ( + + + + + + ) : keys.length === 0 ? ( + + + No API keys yet. + + + ) : ( + keys.map((k) => ( + + {k.name} + {k.key_prefix}… + {k.created_by} + {fmt(k.created_at)} + {fmt(k.last_used_at)} + + {k.revoked ? ( + Revoked + ) : ( + Active + )} + + + {!k.revoked && ( + + )} + + + )) + )} + +
+
+ + + setRevoking(null)} onRevoked={load} /> + + ) +} + +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(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 ( + + + {created ? ( + <> + + API key created + + Copy this key now. For security it will not be shown again. + + +
+ +
+ + {created.key} + + +
+
+ + + + + ) : ( + <> + + Create API key + + Generate a new API key for programmatic access. + + +
+ + setName(e.target.value)} + autoComplete="off" + /> +
+ + + + + + )} +
+
+ ) +} + +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 ( + !o && onClose()}> + + + Revoke API key? + + {apikey?.name} will stop working immediately. This + cannot be undone. + + + + + + + + + ) +} diff --git a/frontend/src/pages/ChangePassword.tsx b/frontend/src/pages/ChangePassword.tsx new file mode 100644 index 0000000..f3b83e7 --- /dev/null +++ b/frontend/src/pages/ChangePassword.tsx @@ -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(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 = ( +
+ {!forced && ( +
+ + setCurrent(e.target.value)} + /> +
+ )} +
+ + setNext(e.target.value)} + /> + {tooShort && ( +

Must be at least {MIN_LENGTH} characters.

+ )} +
+
+ + setConfirm(e.target.value)} + /> + {mismatch &&

Passwords do not match.

} +
+ {error &&

{error}

} + +
+ ) + + if (forced) { + return ( +
+ + +
+ +
+ Change your password + You must change your password before continuing. +
+ {form} +
+
+ ) + } + + return ( +
+ + + Change password + Update the password for your account. + + {form} + +
+ ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index f60be0f..06b2167 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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(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() {
- +
RADIUS Admin - Enter your API key to continue + Sign in to continue
- + setKey(e.target.value)} - autoComplete="off" + value={username} + onChange={(e) => setUsername(e.target.value)} + autoComplete="username" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" />
{error &&

{error}

} -
diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..03ef877 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -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([]) + 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 ( +
+
+
+

Activity log

+

{total} events

+
+
+ setUsername(e.target.value)} + /> + +
+
+ +
+ + + + Time + User + Action + Detail + IP address + + + + {loading && items.length === 0 ? ( + + + + + + ) : items.length === 0 ? ( + + + No activity recorded. + + + ) : ( + items.map((row) => ( + + {fmt(row.created_at)} + {row.username} + {row.action} + {row.detail ?? '—'} + + {row.ip_address ?? '—'} + + + )) + )} + +
+ +
+
+ ) +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..fc7c278 --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [addOpen, setAddOpen] = useState(false) + const [resetting, setResetting] = useState(null) + const [deleting, setDeleting] = useState(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 ( +
+
+
+

Users

+

{users.length} accounts

+
+
+ + +
+
+ +
+ + + + Username + Role + Password + Created + Actions + + + + {loading && users.length === 0 ? ( + + + + + + ) : users.length === 0 ? ( + + + No users. + + + ) : ( + users.map((u) => ( + + {u.username} + + + {u.is_admin ? 'Admin' : 'User'} + + + + {u.must_change_password ? ( + Must change + ) : ( + + )} + + {fmt(u.created_at)} + +
+ + {u.username !== user?.username && ( + + )} +
+
+
+ )) + )} +
+
+
+ + + setResetting(null)} onSaved={load} /> + setDeleting(null)} onDeleted={load} /> +
+ ) +} + +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 ( + + + + Add user + Create a new admin-portal account. + +
+ + setUsername(e.target.value)} autoComplete="off" /> + + + setPassword(e.target.value)} + autoComplete="new-password" + /> + + + +
+ + + + +
+
+ ) +} + +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 ( + !o && onClose()}> + + + Reset password + + Sets a new password for {user?.username}. + + +
+ + setPassword(e.target.value)} + autoComplete="new-password" + /> + + +
+ + + + +
+
+ ) +} + +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 ( + !o && onClose()}> + + + Delete user? + + This permanently removes {user?.username}. + + + + + + + + + ) +} + +function Field({ + label, + required, + children, +}: { + label: string + required?: boolean + children: ReactNode +}) { + return ( +
+ + {children} +
+ ) +}