feat: Setup Wizard for first-run configuration
Container starts with only DB credentials. On first visit, a step-by-step wizard guides through admin password, weather, HA, Vikunja, Unraid, MQTT, n8n and news configuration. Backward-compat: ADMIN_PASSWORD env skips wizard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e25d055ba2
commit
6651bfaf60
9 changed files with 1042 additions and 34 deletions
|
|
@ -112,8 +112,9 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
# --- Register Routers ---
|
||||
from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402
|
||||
from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, setup, tasks, weather # noqa: E402
|
||||
|
||||
app.include_router(setup.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(admin.router)
|
||||
app.include_router(weather.router)
|
||||
|
|
@ -128,6 +129,13 @@ app.include_router(dashboard.router)
|
|||
static_dir = Path(__file__).parent.parent / "static"
|
||||
if static_dir.is_dir():
|
||||
# SPA fallback: serve index.html for any non-API path
|
||||
@app.get("/setup{full_path:path}")
|
||||
async def setup_spa_fallback(full_path: str = ""):
|
||||
index = static_dir / "index.html"
|
||||
if index.exists():
|
||||
return FileResponse(str(index))
|
||||
return {"error": "Frontend not built"}
|
||||
|
||||
@app.get("/admin/{full_path:path}")
|
||||
async def admin_spa_fallback(full_path: str):
|
||||
index = static_dir / "index.html"
|
||||
|
|
|
|||
163
server/routers/setup.py
Normal file
163
server/routers/setup.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Setup wizard router — unauthenticated endpoints for first-time setup."""
|
||||
|
||||
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 create_access_token, hash_password, require_admin
|
||||
from server.services import settings_service
|
||||
from server.services.test_connections import TEST_FUNCTIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _require_setup_incomplete() -> None:
|
||||
"""Raise 403 if setup is already complete (admin user exists)."""
|
||||
user = await settings_service.get_admin_user()
|
||||
if user is not None:
|
||||
raise HTTPException(status_code=403, detail="Setup already complete")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SetupStatusResponse(BaseModel):
|
||||
setup_complete: bool
|
||||
integrations: List[Dict[str, Any]]
|
||||
|
||||
|
||||
class CreateAdminRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class CreateAdminResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
|
||||
|
||||
class SetupIntegrationUpdate(BaseModel):
|
||||
config: Dict[str, Any]
|
||||
enabled: bool
|
||||
|
||||
|
||||
class TestConfigRequest(BaseModel):
|
||||
config: Dict[str, Any]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status")
|
||||
async def get_setup_status() -> SetupStatusResponse:
|
||||
"""Check whether first-time setup has been completed."""
|
||||
user = await settings_service.get_admin_user()
|
||||
setup_complete = user is not None
|
||||
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
if not setup_complete:
|
||||
try:
|
||||
raw = await settings_service.get_integrations()
|
||||
integrations = [
|
||||
{"type": i["type"], "name": i["name"], "enabled": i["enabled"]}
|
||||
for i in raw
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return SetupStatusResponse(
|
||||
setup_complete=setup_complete,
|
||||
integrations=integrations,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin")
|
||||
async def create_admin(
|
||||
body: CreateAdminRequest,
|
||||
_: None = Depends(_require_setup_incomplete),
|
||||
) -> CreateAdminResponse:
|
||||
"""Create the admin user (step 1 of wizard)."""
|
||||
if len(body.password) < 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Passwort muss mindestens 6 Zeichen haben",
|
||||
)
|
||||
|
||||
await settings_service.create_admin_user("admin", hash_password(body.password))
|
||||
token = create_access_token("admin")
|
||||
logger.info("Setup wizard: admin user created")
|
||||
return CreateAdminResponse(token=token, username="admin")
|
||||
|
||||
|
||||
@router.put("/integration/{type_name}")
|
||||
async def setup_integration(
|
||||
type_name: str,
|
||||
body: SetupIntegrationUpdate,
|
||||
admin_user: str = Depends(require_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""Save integration config during setup."""
|
||||
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=existing["name"],
|
||||
config=body.config,
|
||||
enabled=body.enabled,
|
||||
display_order=existing["display_order"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/integration/{type_name}/test")
|
||||
async def test_integration_config(
|
||||
type_name: str,
|
||||
body: TestConfigRequest,
|
||||
admin_user: str = Depends(require_admin),
|
||||
) -> Dict[str, Any]:
|
||||
"""Test an integration with unsaved config values."""
|
||||
test_fn = TEST_FUNCTIONS.get(type_name)
|
||||
if test_fn is None:
|
||||
return {"success": False, "message": f"Kein Test für '{type_name}' verfügbar"}
|
||||
return await test_fn(body.config)
|
||||
|
||||
|
||||
@router.post("/complete")
|
||||
async def complete_setup(
|
||||
admin_user: str = Depends(require_admin),
|
||||
) -> Dict[str, str]:
|
||||
"""Finalize setup: reload settings and start services."""
|
||||
from server.config import get_settings, reload_settings
|
||||
from server.services.mqtt_service import mqtt_service
|
||||
|
||||
await reload_settings()
|
||||
|
||||
cfg = get_settings()
|
||||
if cfg.mqtt_enabled and cfg.mqtt_host:
|
||||
try:
|
||||
await mqtt_service.start(
|
||||
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("Setup wizard: MQTT started (%s:%d)", cfg.mqtt_host, cfg.mqtt_port)
|
||||
except Exception:
|
||||
logger.exception("Setup wizard: failed to start MQTT")
|
||||
|
||||
logger.info("Setup wizard completed successfully")
|
||||
return {"status": "ok", "message": "Setup abgeschlossen"}
|
||||
|
|
@ -22,17 +22,16 @@ async def seed_if_empty() -> None:
|
|||
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,
|
||||
if admin_pw:
|
||||
# Explicit ENV password → seed admin user (backward compat)
|
||||
await settings_service.create_admin_user("admin", hash_password(admin_pw))
|
||||
logger.info("Admin user seeded from ADMIN_PASSWORD env")
|
||||
else:
|
||||
# No password set → setup wizard will handle admin creation
|
||||
logger.info(
|
||||
"No ADMIN_PASSWORD set — setup wizard will handle first-time "
|
||||
"configuration. Visit the web UI to complete setup."
|
||||
)
|
||||
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()
|
||||
|
|
@ -103,6 +102,16 @@ async def seed_if_empty() -> None:
|
|||
"enabled": bool(os.getenv("MQTT_HOST")),
|
||||
"display_order": 5,
|
||||
},
|
||||
{
|
||||
"type": "n8n",
|
||||
"name": "n8n Webhooks",
|
||||
"config": {
|
||||
"url": os.getenv("N8N_URL", ""),
|
||||
"api_key": os.getenv("N8N_API_KEY", ""),
|
||||
},
|
||||
"enabled": bool(os.getenv("N8N_URL")),
|
||||
"display_order": 6,
|
||||
},
|
||||
]
|
||||
|
||||
for seed in seed_integrations:
|
||||
|
|
|
|||
|
|
@ -136,6 +136,27 @@ async def test_news_db(config: Dict[str, Any]) -> Dict[str, Any]:
|
|||
return {"success": False, "message": str(exc)}
|
||||
|
||||
|
||||
async def test_n8n(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Test n8n connection by fetching workflows."""
|
||||
url = config.get("url", "")
|
||||
api_key = config.get("api_key", "")
|
||||
if not url or not api_key:
|
||||
return {"success": False, "message": "URL und API Key sind erforderlich"}
|
||||
try:
|
||||
base = url.rstrip("/")
|
||||
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||
r = await client.get(
|
||||
f"{base}/api/v1/workflows",
|
||||
headers={"X-N8N-API-KEY": api_key},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
count = len(data.get("data", []))
|
||||
return {"success": True, "message": f"Verbunden — {count} Workflows gefunden"}
|
||||
except Exception as exc:
|
||||
return {"success": False, "message": str(exc)}
|
||||
|
||||
|
||||
# Map integration type → test function
|
||||
TEST_FUNCTIONS = {
|
||||
"weather": test_weather,
|
||||
|
|
@ -144,4 +165,5 @@ TEST_FUNCTIONS = {
|
|||
"unraid": test_unraid,
|
||||
"mqtt": test_mqtt,
|
||||
"news": test_news_db,
|
||||
"n8n": test_n8n,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue