feat: add Admin Panel with JWT auth, DB settings, and integration management

Complete admin backend with login, where all integrations (weather, news,
Home Assistant, Vikunja, Unraid, MQTT) can be configured via web UI instead
of ENV variables. Two-layer config: ENV seeds DB on first start, then DB
is source of truth. Auto-migration system on startup.

Backend: db.py shared pool, auth.py JWT, settings_service CRUD, seed_service,
admin router (protected), test_connections per integration, config.py rewrite.

Frontend: react-router v6, login page, admin layout with sidebar, 8 settings
pages (General, Weather, News, HA, Vikunja, Unraid, MQTT, ChangePassword),
shared IntegrationForm + TestButton components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam 2026-03-02 10:37:30 +01:00
parent 89ed0c6d0a
commit f6a42c2dd2
40 changed files with 3487 additions and 311 deletions

View file

@ -8,10 +8,10 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from server.config import settings
from server.services import news_service
from server.config import get_settings, reload_settings, settings
from server.services.mqtt_service import mqtt_service
logger = logging.getLogger("daily-briefing")
@ -24,15 +24,15 @@ logging.basicConfig(
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown lifecycle."""
logger.info("Starting Daily Briefing Dashboard...")
logger.info(
"Unraid servers configured: %d",
len(settings.unraid_servers),
)
from server import db
from server.migrations.runner import run_migrations
from server.services.seed_service import seed_if_empty
# Initialize database pool
logger.info("Starting Daily Briefing Dashboard v2.1...")
# 1. Initialize shared database pool (bootstrap from ENV)
try:
await news_service.init_pool(
pool = await db.init_pool(
host=settings.db_host,
port=settings.db_port,
dbname=settings.db_name,
@ -41,36 +41,64 @@ async def lifespan(app: FastAPI):
)
logger.info("Database pool initialized")
except Exception:
logger.exception("Failed to initialize database pool — news will be unavailable")
logger.exception("Failed to initialize database pool — admin + news will be unavailable")
yield
return
# Start MQTT service
if settings.mqtt_host:
# 2. Run database migrations
try:
await run_migrations(pool)
except Exception:
logger.exception("Migration error — some features may not work")
# 3. Seed database from ENV on first run
try:
await seed_if_empty()
except Exception:
logger.exception("Seeding error — admin panel may need manual setup")
# 4. Load settings from database (overrides ENV defaults)
try:
await reload_settings()
cfg = get_settings()
logger.info(
"Settings loaded from DB — %d Unraid servers, MQTT=%s, HA=%s",
len(cfg.unraid_servers),
"enabled" if cfg.mqtt_enabled else "disabled",
"enabled" if cfg.ha_enabled else "disabled",
)
except Exception:
logger.exception("Failed to load settings from DB — using ENV defaults")
cfg = settings
# 5. Start MQTT service if enabled
if cfg.mqtt_enabled and cfg.mqtt_host:
try:
await mqtt_service.start(
host=settings.mqtt_host,
port=settings.mqtt_port,
username=settings.mqtt_username or None,
password=settings.mqtt_password or None,
topics=settings.mqtt_topics,
client_id=settings.mqtt_client_id,
host=cfg.mqtt_host,
port=cfg.mqtt_port,
username=cfg.mqtt_username or None,
password=cfg.mqtt_password or None,
topics=cfg.mqtt_topics,
client_id=cfg.mqtt_client_id,
)
logger.info("MQTT service started (broker %s:%d)", settings.mqtt_host, settings.mqtt_port)
logger.info("MQTT service started (%s:%d)", cfg.mqtt_host, cfg.mqtt_port)
except Exception:
logger.exception("Failed to start MQTT service — MQTT will be unavailable")
logger.exception("Failed to start MQTT service")
else:
logger.info("MQTT disabled — set MQTT_HOST to enable")
logger.info("MQTT disabled — configure via Admin Panel or MQTT_HOST env")
yield
# Shutdown
logger.info("Shutting down...")
await mqtt_service.stop()
await news_service.close_pool()
await db.close_pool()
app = FastAPI(
title="Daily Briefing",
version="2.0.0",
version="2.1.0",
lifespan=lifespan,
)
@ -84,8 +112,10 @@ app.add_middleware(
)
# --- Register Routers ---
from server.routers import dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(weather.router)
app.include_router(news.router)
app.include_router(servers.router)
@ -97,6 +127,14 @@ app.include_router(dashboard.router)
# --- Serve static frontend (production) ---
static_dir = Path(__file__).parent.parent / "static"
if static_dir.is_dir():
# SPA fallback: serve index.html for any non-API path
@app.get("/admin/{full_path:path}")
async def admin_spa_fallback(full_path: str):
index = static_dir / "index.html"
if index.exists():
return FileResponse(str(index))
return {"error": "Frontend not built"}
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
logger.info("Serving static frontend from %s", static_dir)
else:
@ -104,6 +142,10 @@ else:
async def root():
return {
"status": "ok",
"message": "Daily Briefing API — Frontend not built yet",
"endpoints": ["/api/all", "/api/weather", "/api/news", "/api/servers", "/api/ha", "/api/tasks"],
"message": "Daily Briefing API v2.1 — Frontend not built yet",
"endpoints": [
"/api/all", "/api/weather", "/api/news", "/api/servers",
"/api/ha", "/api/tasks", "/api/mqtt",
"/api/auth/login", "/api/admin/integrations",
],
}