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>
163 lines
5 KiB
Python
163 lines
5 KiB
Python
"""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"}
|