diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5d017d0..fbceded 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -82,7 +82,7 @@ class UIConfig(BaseModel): class AdminConfig(BaseModel): """Admin panel settings.""" - enabled: bool = False + enabled: bool = True username: str = "admin" password_hash: str = "" diff --git a/backend/app/main.py b/backend/app/main.py index 57091c6..3a64e49 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,7 +50,7 @@ async def lifespan(app: FastAPI): logger.info("Config loaded from defaults + env vars (no YAML file)") # --- TLS warning --- - if config.admin.enabled: + if config.admin.enabled and config.admin.password_hash: logger.warning( "Admin panel is enabled. Ensure HTTPS is configured via a reverse proxy " "(Caddy, Traefik, nginx) to protect admin credentials in transit." diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 9349705..c4377b3 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,12 +1,15 @@ """Admin API endpoints — protected by require_admin dependency. Settings are persisted to SQLite and survive container restarts. +Admin setup (first-run password creation) is unauthenticated but only +available when no password has been configured yet. """ from __future__ import annotations import logging +import bcrypt from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse @@ -17,6 +20,83 @@ logger = logging.getLogger("mediarip.admin") router = APIRouter(prefix="/admin", tags=["admin"]) +# --------------------------------------------------------------------------- +# Public endpoints (no auth) — admin status + first-run setup +# --------------------------------------------------------------------------- + + +@router.get("/status") +async def admin_status(request: Request) -> dict: + """Public endpoint: is admin enabled, and has initial setup been done?""" + config = request.app.state.config + return { + "enabled": config.admin.enabled, + "setup_complete": bool(config.admin.password_hash), + } + + +@router.post("/setup") +async def admin_setup(request: Request) -> dict: + """First-run setup: create admin credentials. + + Only works when admin is enabled AND no password has been set yet. + After setup, this endpoint returns 403 — use /admin/password to change. + """ + config = request.app.state.config + + if not config.admin.enabled: + return JSONResponse( + status_code=404, + content={"detail": "Admin panel is not enabled"}, + ) + + if config.admin.password_hash: + return JSONResponse( + status_code=403, + content={"detail": "Admin is already configured. Use the change password flow."}, + ) + + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "") + + if not username: + return JSONResponse( + status_code=422, + content={"detail": "Username is required"}, + ) + + if len(password) < 4: + return JSONResponse( + status_code=422, + content={"detail": "Password must be at least 4 characters"}, + ) + + # Hash and persist + password_hash = bcrypt.hashpw( + password.encode("utf-8"), bcrypt.gensalt() + ).decode("utf-8") + + config.admin.username = username + config.admin.password_hash = password_hash + + # Persist to DB so it survives restarts + from app.services.settings import save_settings + db = request.app.state.db + await save_settings(db, { + "admin_username": username, + "admin_password_hash": password_hash, + }) + + logger.info("Admin setup complete — user '%s' created", username) + return {"status": "ok", "username": username} + + +# --------------------------------------------------------------------------- +# Authenticated endpoints +# --------------------------------------------------------------------------- + + @router.get("/sessions") async def list_sessions( request: Request, @@ -351,9 +431,7 @@ async def change_password( request: Request, _admin: str = Depends(require_admin), ) -> dict: - """Change admin password. Persisted in-memory only (set via env var for persistence).""" - import bcrypt - + """Change admin password. Persisted to SQLite for durability.""" body = await request.json() current = body.get("current_password", "") new_pw = body.get("new_password", "") @@ -387,8 +465,13 @@ async def change_password( new_hash = bcrypt.hashpw(new_pw.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") config.admin.password_hash = new_hash - logger.info("Admin password changed by user '%s'", _admin) + # Persist to DB + from app.services.settings import save_settings + db = request.app.state.db + await save_settings(db, {"admin_password_hash": new_hash}) + + logger.info("Admin password changed by user '%s'", _admin) return {"status": "ok", "message": "Password changed successfully"} diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 9e48162..aa75a9c 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -29,4 +29,6 @@ async def public_config(request: Request) -> dict: "default_audio_format": getattr(request.app.state, "_default_audio_format", "auto"), "privacy_mode": config.purge.privacy_mode, "privacy_retention_hours": config.purge.privacy_retention_hours, + "admin_enabled": config.admin.enabled, + "admin_setup_complete": bool(config.admin.password_hash), } diff --git a/backend/app/services/settings.py b/backend/app/services/settings.py index 2e4e4dc..01882b1 100644 --- a/backend/app/services/settings.py +++ b/backend/app/services/settings.py @@ -31,6 +31,7 @@ ADMIN_WRITABLE_KEYS = { "session_mode", "session_timeout_hours", "admin_username", + "admin_password_hash", "purge_enabled", "purge_max_age_hours", } @@ -99,6 +100,8 @@ def apply_persisted_to_config(config, settings: dict) -> None: config.session.timeout_hours = settings["session_timeout_hours"] if "admin_username" in settings: config.admin.username = settings["admin_username"] + if "admin_password_hash" in settings: + config.admin.password_hash = settings["admin_password_hash"] if "purge_enabled" in settings: config.purge.enabled = settings["purge_enabled"] if "purge_max_age_hours" in settings: diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 3aeb55a..ee2bce4 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -29,7 +29,9 @@ class TestZeroConfig: def test_admin_defaults(self): config = AppConfig() - assert config.admin.enabled is False + assert config.admin.enabled is True + assert config.admin.username == "admin" + assert config.admin.password_hash == "" def test_source_templates_default_entries(self): config = AppConfig() diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7901b72..5c2a83c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -77,6 +77,8 @@ export interface PublicConfig { default_audio_format: string privacy_mode: boolean privacy_retention_hours: number + admin_enabled: boolean + admin_setup_complete: boolean } export interface HealthStatus { diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 08c361e..d3a74ce 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -5,6 +5,7 @@ import { useAdminStore } from '@/stores/admin' import { useConfigStore } from '@/stores/config' import { api } from '@/api/client' import AdminLogin from './AdminLogin.vue' +import AdminSetup from './AdminSetup.vue' const store = useAdminStore() const configStore = useConfigStore() @@ -194,7 +195,16 @@ function formatFilesize(bytes: number | null): string {