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:
parent
89ed0c6d0a
commit
f6a42c2dd2
40 changed files with 3487 additions and 311 deletions
186
server/routers/admin.py
Normal file
186
server/routers/admin.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Admin router — protected CRUD for settings, integrations, and MQTT subscriptions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth import require_admin
|
||||
from server.services import settings_service
|
||||
from server.services.test_connections import TEST_FUNCTIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request/Response Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
settings: Dict[str, Any]
|
||||
|
||||
|
||||
class IntegrationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
enabled: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class MqttSubscriptionCreate(BaseModel):
|
||||
topic_pattern: str
|
||||
display_name: str = ""
|
||||
category: str = "other"
|
||||
unit: str = ""
|
||||
widget_type: str = "value"
|
||||
enabled: bool = True
|
||||
display_order: int = 0
|
||||
|
||||
|
||||
class MqttSubscriptionUpdate(BaseModel):
|
||||
topic_pattern: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
widget_type: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_settings() -> Dict[str, Any]:
|
||||
"""Return all app settings."""
|
||||
return await settings_service.get_all_settings()
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(body: SettingsUpdate) -> Dict[str, str]:
|
||||
"""Bulk update settings."""
|
||||
await settings_service.bulk_set_settings(body.settings)
|
||||
# Reload in-memory settings
|
||||
await _reload_app_settings()
|
||||
return {"status": "ok", "message": f"Updated {len(body.settings)} setting(s)"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integrations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/integrations")
|
||||
async def list_integrations() -> List[Dict[str, Any]]:
|
||||
"""List all integration configs."""
|
||||
return await settings_service.get_integrations()
|
||||
|
||||
|
||||
@router.get("/integrations/{type_name}")
|
||||
async def get_integration(type_name: str) -> Dict[str, Any]:
|
||||
"""Get a single integration config."""
|
||||
result = await settings_service.get_integration(type_name)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/integrations/{type_name}")
|
||||
async def update_integration(type_name: str, body: IntegrationUpdate) -> Dict[str, Any]:
|
||||
"""Update an integration config."""
|
||||
existing = await settings_service.get_integration(type_name)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||
|
||||
result = await settings_service.upsert_integration(
|
||||
type_name=type_name,
|
||||
name=body.name or existing["name"],
|
||||
config=body.config if body.config is not None else existing["config"],
|
||||
enabled=body.enabled if body.enabled is not None else existing["enabled"],
|
||||
display_order=body.display_order if body.display_order is not None else existing["display_order"],
|
||||
)
|
||||
|
||||
# Reload in-memory settings
|
||||
await _reload_app_settings()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/integrations/{type_name}/test")
|
||||
async def test_integration(type_name: str) -> Dict[str, Any]:
|
||||
"""Test an integration connection."""
|
||||
integration = await settings_service.get_integration(type_name)
|
||||
if integration is None:
|
||||
raise HTTPException(status_code=404, detail=f"Integration '{type_name}' not found")
|
||||
|
||||
test_fn = TEST_FUNCTIONS.get(type_name)
|
||||
if test_fn is None:
|
||||
return {"success": False, "message": f"No test available for '{type_name}'"}
|
||||
|
||||
return await test_fn(integration["config"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MQTT Subscriptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/mqtt/subscriptions")
|
||||
async def list_mqtt_subscriptions() -> List[Dict[str, Any]]:
|
||||
"""List all MQTT subscriptions."""
|
||||
return await settings_service.get_mqtt_subscriptions()
|
||||
|
||||
|
||||
@router.post("/mqtt/subscriptions")
|
||||
async def create_mqtt_subscription(body: MqttSubscriptionCreate) -> Dict[str, Any]:
|
||||
"""Create a new MQTT subscription."""
|
||||
return await settings_service.create_mqtt_subscription(
|
||||
topic_pattern=body.topic_pattern,
|
||||
display_name=body.display_name,
|
||||
category=body.category,
|
||||
unit=body.unit,
|
||||
widget_type=body.widget_type,
|
||||
enabled=body.enabled,
|
||||
display_order=body.display_order,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/mqtt/subscriptions/{sub_id}")
|
||||
async def update_mqtt_subscription(sub_id: int, body: MqttSubscriptionUpdate) -> Dict[str, Any]:
|
||||
"""Update an MQTT subscription."""
|
||||
fields = body.model_dump(exclude_none=True)
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
result = await settings_service.update_mqtt_subscription(sub_id, **fields)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/mqtt/subscriptions/{sub_id}")
|
||||
async def delete_mqtt_subscription(sub_id: int) -> Dict[str, str]:
|
||||
"""Delete an MQTT subscription."""
|
||||
deleted = await settings_service.delete_mqtt_subscription(sub_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||
return {"status": "ok", "message": "Subscription deleted"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _reload_app_settings() -> None:
|
||||
"""Reload the in-memory Settings object from the database."""
|
||||
try:
|
||||
from server.config import reload_settings
|
||||
await reload_settings()
|
||||
except Exception:
|
||||
logger.exception("Failed to reload settings after admin change")
|
||||
79
server/routers/auth.py
Normal file
79
server/routers/auth.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Auth router — login and password management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from server.auth import (
|
||||
create_access_token,
|
||||
hash_password,
|
||||
require_admin,
|
||||
verify_password,
|
||||
)
|
||||
from server.services import settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(body: LoginRequest) -> Dict[str, Any]:
|
||||
"""Authenticate admin and return a JWT."""
|
||||
user = await settings_service.get_admin_user()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=503, detail="No admin user configured")
|
||||
|
||||
if body.username != user["username"]:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not verify_password(body.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
token = create_access_token(user["username"])
|
||||
return {
|
||||
"token": token,
|
||||
"username": user["username"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(admin_user: str = Depends(require_admin)) -> Dict[str, str]:
|
||||
"""Return the authenticated admin username. Used to verify token validity."""
|
||||
return {"username": admin_user}
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
admin_user: str = Depends(require_admin),
|
||||
) -> Dict[str, str]:
|
||||
"""Change the admin password."""
|
||||
user = await settings_service.get_admin_user()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=500, detail="Admin user not found")
|
||||
|
||||
if not verify_password(body.current_password, user["password_hash"]):
|
||||
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||
|
||||
if len(body.new_password) < 6:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 6 characters")
|
||||
|
||||
await settings_service.update_admin_password(
|
||||
user["id"], hash_password(body.new_password)
|
||||
)
|
||||
return {"status": "ok", "message": "Password changed successfully"}
|
||||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict
|
|||
from fastapi import APIRouter
|
||||
|
||||
from server.cache import cache
|
||||
from server.config import settings
|
||||
from server.config import get_settings
|
||||
from server.services.ha_service import fetch_ha_data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -36,12 +36,12 @@ async def get_ha() -> Dict[str, Any]:
|
|||
# --- cache miss -----------------------------------------------------------
|
||||
try:
|
||||
data: Dict[str, Any] = await fetch_ha_data(
|
||||
settings.ha_url,
|
||||
settings.ha_token,
|
||||
get_settings().ha_url,
|
||||
get_settings().ha_token,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch Home Assistant data")
|
||||
return {"error": True, "message": str(exc)}
|
||||
|
||||
await cache.set(CACHE_KEY, data, settings.ha_cache_ttl)
|
||||
await cache.set(CACHE_KEY, data, get_settings().ha_cache_ttl)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
|
|||
from fastapi import APIRouter, Query
|
||||
|
||||
from server.cache import cache
|
||||
from server.config import settings
|
||||
from server.config import get_settings
|
||||
from server.services.news_service import get_news, get_news_count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -50,7 +50,7 @@ async def get_news_articles(
|
|||
total: int = 0
|
||||
|
||||
try:
|
||||
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=settings.news_max_age_hours)
|
||||
articles = await get_news(limit=limit, offset=offset, category=category, max_age_hours=get_settings().news_max_age_hours)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch news articles")
|
||||
return {
|
||||
|
|
@ -63,7 +63,7 @@ async def get_news_articles(
|
|||
}
|
||||
|
||||
try:
|
||||
total = await get_news_count(max_age_hours=settings.news_max_age_hours, category=category)
|
||||
total = await get_news_count(max_age_hours=get_settings().news_max_age_hours, category=category)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch news count")
|
||||
# We still have articles -- return them with total = len(articles)
|
||||
|
|
@ -76,5 +76,5 @@ async def get_news_articles(
|
|||
"offset": offset,
|
||||
}
|
||||
|
||||
await cache.set(key, payload, settings.news_cache_ttl)
|
||||
await cache.set(key, payload, get_settings().news_cache_ttl)
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict, List
|
|||
from fastapi import APIRouter
|
||||
|
||||
from server.cache import cache
|
||||
from server.config import settings
|
||||
from server.config import get_settings
|
||||
from server.services.unraid_service import ServerConfig, fetch_all_servers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -42,7 +42,7 @@ async def get_servers() -> Dict[str, Any]:
|
|||
api_key=srv.api_key,
|
||||
port=srv.port,
|
||||
)
|
||||
for srv in settings.unraid_servers
|
||||
for srv in get_settings().unraid_servers
|
||||
]
|
||||
|
||||
servers_data: List[Dict[str, Any]] = []
|
||||
|
|
@ -60,5 +60,5 @@ async def get_servers() -> Dict[str, Any]:
|
|||
"servers": servers_data,
|
||||
}
|
||||
|
||||
await cache.set(CACHE_KEY, payload, settings.unraid_cache_ttl)
|
||||
await cache.set(CACHE_KEY, payload, get_settings().unraid_cache_ttl)
|
||||
return payload
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import Any, Dict
|
|||
from fastapi import APIRouter
|
||||
|
||||
from server.cache import cache
|
||||
from server.config import settings
|
||||
from server.config import get_settings
|
||||
from server.services.vikunja_service import fetch_tasks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -36,12 +36,12 @@ async def get_tasks() -> Dict[str, Any]:
|
|||
# --- cache miss -----------------------------------------------------------
|
||||
try:
|
||||
data: Dict[str, Any] = await fetch_tasks(
|
||||
settings.vikunja_url,
|
||||
settings.vikunja_token,
|
||||
get_settings().vikunja_url,
|
||||
get_settings().vikunja_token,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to fetch Vikunja tasks")
|
||||
return {"error": True, "message": str(exc)}
|
||||
|
||||
await cache.set(CACHE_KEY, data, settings.vikunja_cache_ttl)
|
||||
await cache.set(CACHE_KEY, data, get_settings().vikunja_cache_ttl)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from typing import Any, Dict, List
|
|||
from fastapi import APIRouter
|
||||
|
||||
from server.cache import cache
|
||||
from server.config import settings
|
||||
from server.config import get_settings
|
||||
from server.services.weather_service import fetch_hourly_forecast, fetch_weather
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -43,9 +43,9 @@ async def get_weather() -> Dict[str, Any]:
|
|||
hourly_data: List[Dict[str, Any]] = []
|
||||
|
||||
results = await asyncio.gather(
|
||||
_safe_fetch_weather(settings.weather_location),
|
||||
_safe_fetch_weather(settings.weather_location_secondary),
|
||||
_safe_fetch_hourly(settings.weather_location),
|
||||
_safe_fetch_weather(get_settings().weather_location),
|
||||
_safe_fetch_weather(get_settings().weather_location_secondary),
|
||||
_safe_fetch_hourly(get_settings().weather_location),
|
||||
return_exceptions=False, # we handle errors inside the helpers
|
||||
)
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ async def get_weather() -> Dict[str, Any]:
|
|||
"hourly": hourly_data,
|
||||
}
|
||||
|
||||
await cache.set(CACHE_KEY, payload, settings.weather_cache_ttl)
|
||||
await cache.set(CACHE_KEY, payload, get_settings().weather_cache_ttl)
|
||||
return payload
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue