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
150
server/services/seed_service.py
Normal file
150
server/services/seed_service.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""First-run seeder: populates DB from ENV defaults when tables are empty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from server.auth import hash_password
|
||||
from server.db import get_pool
|
||||
from server.services import settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def seed_if_empty() -> None:
|
||||
"""Check if admin tables are empty and seed with ENV-derived values."""
|
||||
pool = await get_pool()
|
||||
|
||||
# ---- Admin User ----
|
||||
user = await settings_service.get_admin_user()
|
||||
if user is None:
|
||||
admin_pw = os.getenv("ADMIN_PASSWORD", "")
|
||||
if not admin_pw:
|
||||
admin_pw = secrets.token_urlsafe(16)
|
||||
logger.warning(
|
||||
"=" * 60 + "\n"
|
||||
" No ADMIN_PASSWORD set — generated: %s\n"
|
||||
" Set ADMIN_PASSWORD env to use your own.\n" +
|
||||
"=" * 60,
|
||||
admin_pw,
|
||||
)
|
||||
await settings_service.create_admin_user("admin", hash_password(admin_pw))
|
||||
logger.info("Admin user seeded from ENV")
|
||||
|
||||
# ---- Integrations ----
|
||||
existing = await settings_service.get_integrations()
|
||||
existing_types = {i["type"] for i in existing}
|
||||
|
||||
seed_integrations = [
|
||||
{
|
||||
"type": "weather",
|
||||
"name": "Wetter (wttr.in)",
|
||||
"config": {
|
||||
"location": os.getenv("WEATHER_LOCATION", "Leverkusen"),
|
||||
"location_secondary": os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia"),
|
||||
},
|
||||
"enabled": True,
|
||||
"display_order": 0,
|
||||
},
|
||||
{
|
||||
"type": "news",
|
||||
"name": "News (PostgreSQL)",
|
||||
"config": {
|
||||
"max_age_hours": int(os.getenv("NEWS_MAX_AGE_HOURS", "48")),
|
||||
},
|
||||
"enabled": True,
|
||||
"display_order": 1,
|
||||
},
|
||||
{
|
||||
"type": "ha",
|
||||
"name": "Home Assistant",
|
||||
"config": {
|
||||
"url": os.getenv("HA_URL", ""),
|
||||
"token": os.getenv("HA_TOKEN", ""),
|
||||
},
|
||||
"enabled": bool(os.getenv("HA_URL")),
|
||||
"display_order": 2,
|
||||
},
|
||||
{
|
||||
"type": "vikunja",
|
||||
"name": "Vikunja Tasks",
|
||||
"config": {
|
||||
"url": os.getenv("VIKUNJA_URL", ""),
|
||||
"token": os.getenv("VIKUNJA_TOKEN", ""),
|
||||
"private_projects": [3, 4],
|
||||
"sams_projects": [2, 5],
|
||||
},
|
||||
"enabled": bool(os.getenv("VIKUNJA_URL")),
|
||||
"display_order": 3,
|
||||
},
|
||||
{
|
||||
"type": "unraid",
|
||||
"name": "Unraid Server",
|
||||
"config": {
|
||||
"servers": _parse_unraid_env(),
|
||||
},
|
||||
"enabled": bool(os.getenv("UNRAID_SERVERS")),
|
||||
"display_order": 4,
|
||||
},
|
||||
{
|
||||
"type": "mqtt",
|
||||
"name": "MQTT Broker",
|
||||
"config": {
|
||||
"host": os.getenv("MQTT_HOST", ""),
|
||||
"port": int(os.getenv("MQTT_PORT", "1883")),
|
||||
"username": os.getenv("MQTT_USERNAME", ""),
|
||||
"password": os.getenv("MQTT_PASSWORD", ""),
|
||||
"client_id": os.getenv("MQTT_CLIENT_ID", "daily-briefing"),
|
||||
"topics": _parse_mqtt_topics(),
|
||||
},
|
||||
"enabled": bool(os.getenv("MQTT_HOST")),
|
||||
"display_order": 5,
|
||||
},
|
||||
]
|
||||
|
||||
for seed in seed_integrations:
|
||||
if seed["type"] not in existing_types:
|
||||
await settings_service.upsert_integration(
|
||||
type_name=seed["type"],
|
||||
name=seed["name"],
|
||||
config=seed["config"],
|
||||
enabled=seed["enabled"],
|
||||
display_order=seed["display_order"],
|
||||
)
|
||||
logger.info("Seeded integration: %s", seed["type"])
|
||||
|
||||
# ---- App Settings ----
|
||||
existing_settings = await settings_service.get_all_settings()
|
||||
if not existing_settings:
|
||||
default_settings = [
|
||||
("weather_cache_ttl", "1800", "int", "cache", "Wetter Cache TTL", "Sekunden"),
|
||||
("ha_cache_ttl", "30", "int", "cache", "HA Cache TTL", "Sekunden"),
|
||||
("vikunja_cache_ttl", "60", "int", "cache", "Vikunja Cache TTL", "Sekunden"),
|
||||
("unraid_cache_ttl", "15", "int", "cache", "Unraid Cache TTL", "Sekunden"),
|
||||
("news_cache_ttl", "300", "int", "cache", "News Cache TTL", "Sekunden"),
|
||||
("ws_interval", "15", "int", "general", "WebSocket Intervall", "Sekunden"),
|
||||
]
|
||||
for key, value, vtype, cat, label, desc in default_settings:
|
||||
await settings_service.set_setting(key, value, vtype, cat, label, desc)
|
||||
logger.info("Seeded %d default settings", len(default_settings))
|
||||
|
||||
|
||||
def _parse_unraid_env() -> list:
|
||||
"""Parse UNRAID_SERVERS env var."""
|
||||
raw = os.getenv("UNRAID_SERVERS", "[]")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
|
||||
def _parse_mqtt_topics() -> list:
|
||||
"""Parse MQTT_TOPICS env var."""
|
||||
raw = os.getenv("MQTT_TOPICS", "#")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
Loading…
Add table
Add a link
Reference in a new issue