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
|
|
@ -3,37 +3,21 @@ services:
|
||||||
build: .
|
build: .
|
||||||
container_name: daily-briefing
|
container_name: daily-briefing
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "9080:8080"
|
||||||
environment:
|
environment:
|
||||||
# ── Required: Database (PostgreSQL) ──
|
# ── Required: Database (external PostgreSQL) ──
|
||||||
- DB_HOST=10.10.10.10
|
- DB_HOST=10.10.10.10
|
||||||
- DB_PORT=5433
|
- DB_PORT=5433
|
||||||
- DB_NAME=openclaw
|
- DB_NAME=openclaw
|
||||||
- DB_USER=sam
|
- DB_USER=sam
|
||||||
- DB_PASSWORD=sam
|
- DB_PASSWORD=sam
|
||||||
|
|
||||||
# ── Required: Admin Panel ──
|
# ── Optional: skip setup wizard by providing ADMIN_PASSWORD ──
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
|
# - ADMIN_PASSWORD=your-password
|
||||||
- JWT_SECRET=${JWT_SECRET:-}
|
# - JWT_SECRET=optional-fixed-secret
|
||||||
|
|
||||||
# ── Seed Values (used on first start only, then DB takes over) ──
|
# ── All integrations are configured via the web setup wizard ──
|
||||||
# Weather
|
# Visit http://host:9080 on first start to complete setup.
|
||||||
- WEATHER_LOCATION=Leverkusen
|
|
||||||
- WEATHER_LOCATION_SECONDARY=Rab,Croatia
|
|
||||||
# Home Assistant
|
|
||||||
- HA_URL=https://homeassistant.daddelolymp.de
|
|
||||||
- HA_TOKEN=${HA_TOKEN}
|
|
||||||
# Vikunja Tasks
|
|
||||||
- VIKUNJA_URL=http://10.10.10.10:3456/api/v1
|
|
||||||
- VIKUNJA_TOKEN=${VIKUNJA_TOKEN}
|
|
||||||
# Unraid Servers (JSON array)
|
|
||||||
- UNRAID_SERVERS=${UNRAID_SERVERS:-[]}
|
|
||||||
# MQTT (optional)
|
|
||||||
- MQTT_HOST=${MQTT_HOST:-}
|
|
||||||
- MQTT_PORT=${MQTT_PORT:-1883}
|
|
||||||
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
|
||||||
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
|
|
||||||
- MQTT_TOPICS=${MQTT_TOPICS:-#}
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: always
|
restart: always
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,9 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Register Routers ---
|
# --- 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(auth.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(weather.router)
|
app.include_router(weather.router)
|
||||||
|
|
@ -128,6 +129,13 @@ app.include_router(dashboard.router)
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
if static_dir.is_dir():
|
if static_dir.is_dir():
|
||||||
# SPA fallback: serve index.html for any non-API path
|
# 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}")
|
@app.get("/admin/{full_path:path}")
|
||||||
async def admin_spa_fallback(full_path: str):
|
async def admin_spa_fallback(full_path: str):
|
||||||
index = static_dir / "index.html"
|
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()
|
user = await settings_service.get_admin_user()
|
||||||
if user is None:
|
if user is None:
|
||||||
admin_pw = os.getenv("ADMIN_PASSWORD", "")
|
admin_pw = os.getenv("ADMIN_PASSWORD", "")
|
||||||
if not admin_pw:
|
if admin_pw:
|
||||||
admin_pw = secrets.token_urlsafe(16)
|
# Explicit ENV password → seed admin user (backward compat)
|
||||||
logger.warning(
|
await settings_service.create_admin_user("admin", hash_password(admin_pw))
|
||||||
"=" * 60 + "\n"
|
logger.info("Admin user seeded from ADMIN_PASSWORD env")
|
||||||
" No ADMIN_PASSWORD set — generated: %s\n"
|
else:
|
||||||
" Set ADMIN_PASSWORD env to use your own.\n" +
|
# No password set → setup wizard will handle admin creation
|
||||||
"=" * 60,
|
logger.info(
|
||||||
admin_pw,
|
"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 ----
|
# ---- Integrations ----
|
||||||
existing = await settings_service.get_integrations()
|
existing = await settings_service.get_integrations()
|
||||||
|
|
@ -103,6 +102,16 @@ async def seed_if_empty() -> None:
|
||||||
"enabled": bool(os.getenv("MQTT_HOST")),
|
"enabled": bool(os.getenv("MQTT_HOST")),
|
||||||
"display_order": 5,
|
"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:
|
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)}
|
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
|
# Map integration type → test function
|
||||||
TEST_FUNCTIONS = {
|
TEST_FUNCTIONS = {
|
||||||
"weather": test_weather,
|
"weather": test_weather,
|
||||||
|
|
@ -144,4 +165,5 @@ TEST_FUNCTIONS = {
|
||||||
"unraid": test_unraid,
|
"unraid": test_unraid,
|
||||||
"mqtt": test_mqtt,
|
"mqtt": test_mqtt,
|
||||||
"news": test_news_db,
|
"news": test_news_db,
|
||||||
|
"n8n": test_n8n,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { getSetupStatus } from "./setup/api";
|
||||||
|
import SetupWizard from "./setup/SetupWizard";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import AdminLayout from "./admin/AdminLayout";
|
import AdminLayout from "./admin/AdminLayout";
|
||||||
import LoginPage from "./admin/LoginPage";
|
import LoginPage from "./admin/LoginPage";
|
||||||
|
|
@ -12,6 +16,34 @@ import MqttSettings from "./admin/pages/MqttSettings";
|
||||||
import ChangePassword from "./admin/pages/ChangePassword";
|
import ChangePassword from "./admin/pages/ChangePassword";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [setupComplete, setSetupComplete] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSetupStatus()
|
||||||
|
.then((status) => setSetupComplete(status.setup_complete))
|
||||||
|
.catch(() => setSetupComplete(true)); // Fail-safe: assume complete
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Loading while checking setup status
|
||||||
|
if (setupComplete === null) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-base-50">
|
||||||
|
<Loader2 className="w-6 h-6 text-gold animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup not complete: force wizard
|
||||||
|
if (!setupComplete) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/setup" element={<SetupWizard />} />
|
||||||
|
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal app
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Dashboard */}
|
{/* Dashboard */}
|
||||||
|
|
@ -33,6 +65,9 @@ export default function App() {
|
||||||
<Route path="password" element={<ChangePassword />} />
|
<Route path="password" element={<ChangePassword />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Setup redirect (already complete) */}
|
||||||
|
<Route path="/setup" element={<Navigate to="/" replace />} />
|
||||||
|
|
||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
665
web/src/setup/SetupWizard.tsx
Normal file
665
web/src/setup/SetupWizard.tsx
Normal file
|
|
@ -0,0 +1,665 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Lock, Cloud, Home, ListTodo, Server, Radio, Newspaper, Webhook,
|
||||||
|
ArrowRight, ArrowLeft, SkipForward, CheckCircle2, Loader2,
|
||||||
|
Sparkles, AlertCircle, Plus, Trash2, Eye, EyeOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import FormField, { TextInput, NumberInput } from "../admin/components/FormField";
|
||||||
|
import { setAuth } from "../admin/api";
|
||||||
|
import {
|
||||||
|
createAdmin, saveIntegration, testIntegrationConfig,
|
||||||
|
completeSetup, getSetupToken,
|
||||||
|
} from "./api";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step definitions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: "welcome", label: "Willkommen", icon: Sparkles },
|
||||||
|
{ id: "admin", label: "Admin", icon: Lock },
|
||||||
|
{ id: "weather", label: "Wetter", icon: Cloud },
|
||||||
|
{ id: "ha", label: "Home Assistant", icon: Home },
|
||||||
|
{ id: "vikunja", label: "Vikunja", icon: ListTodo },
|
||||||
|
{ id: "unraid", label: "Unraid", icon: Server },
|
||||||
|
{ id: "mqtt", label: "MQTT", icon: Radio },
|
||||||
|
{ id: "n8n", label: "n8n", icon: Webhook },
|
||||||
|
{ id: "news", label: "News", icon: Newspaper },
|
||||||
|
{ id: "complete", label: "Fertig", icon: CheckCircle2 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StepId = (typeof STEPS)[number]["id"];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Unraid server entry */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface ServerEntry {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
api_key: string;
|
||||||
|
port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function SetupWizard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Admin
|
||||||
|
const [adminPw, setAdminPw] = useState("");
|
||||||
|
const [adminPwConfirm, setAdminPwConfirm] = useState("");
|
||||||
|
const [showPw, setShowPw] = useState(false);
|
||||||
|
|
||||||
|
// Configs per integration
|
||||||
|
const [weatherCfg, setWeatherCfg] = useState({ location: "Leverkusen", location_secondary: "Rab,Croatia" });
|
||||||
|
const [haCfg, setHaCfg] = useState({ url: "", token: "" });
|
||||||
|
const [vikunjaCfg, setVikunjaCfg] = useState({ url: "", token: "", private_projects: "3,4", sams_projects: "2,5" });
|
||||||
|
const [unraidServers, setUnraidServers] = useState<ServerEntry[]>([]);
|
||||||
|
const [mqttCfg, setMqttCfg] = useState({ host: "", port: 1883, username: "", password: "", client_id: "daily-briefing" });
|
||||||
|
const [n8nCfg, setN8nCfg] = useState({ url: "", api_key: "" });
|
||||||
|
const [newsCfg, setNewsCfg] = useState({ max_age_hours: 48 });
|
||||||
|
|
||||||
|
// Enabled flags
|
||||||
|
const [haEnabled, setHaEnabled] = useState(false);
|
||||||
|
const [vikunjaEnabled, setVikunjaEnabled] = useState(false);
|
||||||
|
const [unraidEnabled, setUnraidEnabled] = useState(false);
|
||||||
|
const [mqttEnabled, setMqttEnabled] = useState(false);
|
||||||
|
const [n8nEnabled, setN8nEnabled] = useState(false);
|
||||||
|
|
||||||
|
// Test results
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
// Track which integrations were configured
|
||||||
|
const [configured, setConfigured] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const currentStep = STEPS[step];
|
||||||
|
const isLast = step === STEPS.length - 1;
|
||||||
|
|
||||||
|
/* ---- Helpers ---- */
|
||||||
|
|
||||||
|
const clearFeedback = () => {
|
||||||
|
setError("");
|
||||||
|
setTestResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
clearFeedback();
|
||||||
|
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const prev = () => {
|
||||||
|
clearFeedback();
|
||||||
|
setStep((s) => Math.max(s - 1, 0));
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseIntList = (str: string): number[] =>
|
||||||
|
str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
||||||
|
|
||||||
|
/* ---- Step handlers ---- */
|
||||||
|
|
||||||
|
const handleAdminCreate = async () => {
|
||||||
|
if (adminPw.length < 6) {
|
||||||
|
setError("Passwort muss mindestens 6 Zeichen haben");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adminPw !== adminPwConfirm) {
|
||||||
|
setError("Passwörter stimmen nicht überein");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await createAdmin(adminPw);
|
||||||
|
next();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveIntegration = async (type: string, config: Record<string, unknown>, enabled: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await saveIntegration(type, config, enabled);
|
||||||
|
if (enabled) {
|
||||||
|
setConfigured((prev) => new Set(prev).add(type));
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async (type: string, config: Record<string, unknown>) => {
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await testIntegrationConfig(type, config);
|
||||||
|
setTestResult(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
setTestResult({ success: false, message: err.message });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await completeSetup();
|
||||||
|
const token = getSetupToken();
|
||||||
|
if (token) {
|
||||||
|
setAuth(token, "admin");
|
||||||
|
}
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipIntegration = async (type: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
// Save with enabled=false to persist any partial config
|
||||||
|
const cfgMap: Record<string, Record<string, unknown>> = {
|
||||||
|
ha: haCfg,
|
||||||
|
vikunja: { ...vikunjaCfg, private_projects: parseIntList(vikunjaCfg.private_projects), sams_projects: parseIntList(vikunjaCfg.sams_projects) },
|
||||||
|
unraid: { servers: unraidServers },
|
||||||
|
mqtt: mqttCfg,
|
||||||
|
n8n: n8nCfg,
|
||||||
|
};
|
||||||
|
await saveIntegration(type, cfgMap[type] || {}, false);
|
||||||
|
} catch {
|
||||||
|
// Ignore skip errors
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---- Unraid server helpers ---- */
|
||||||
|
|
||||||
|
const addServer = () => setUnraidServers((p) => [...p, { name: "", host: "", api_key: "", port: 80 }]);
|
||||||
|
const removeServer = (i: number) => setUnraidServers((p) => p.filter((_, idx) => idx !== i));
|
||||||
|
const updateServer = (i: number, field: keyof ServerEntry, value: string | number) =>
|
||||||
|
setUnraidServers((p) => p.map((s, idx) => (idx === i ? { ...s, [field]: value } : s)));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Render */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-50 flex flex-col">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full px-4 pt-6 pb-2">
|
||||||
|
<div className="max-w-2xl mx-auto flex items-center gap-1">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
const done = i < step;
|
||||||
|
const active = i === step;
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all ${
|
||||||
|
done
|
||||||
|
? "bg-gold/20 text-gold"
|
||||||
|
: active
|
||||||
|
? "bg-gold text-base-900"
|
||||||
|
: "bg-white/5 text-base-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{done ? <CheckCircle2 className="w-4 h-4" /> : <Icon className="w-3.5 h-3.5" />}
|
||||||
|
</div>
|
||||||
|
<span className={`text-[9px] font-medium tracking-wide ${active ? "text-gold" : done ? "text-base-400" : "text-base-600"}`}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Progress line */}
|
||||||
|
<div className="max-w-2xl mx-auto mt-2 h-0.5 bg-white/5 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gold/60 transition-all duration-500"
|
||||||
|
style={{ width: `${(step / (STEPS.length - 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 flex items-start justify-center px-4 py-8">
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
{/* Step card */}
|
||||||
|
<div className="glass-card p-8 animate-fade-in">
|
||||||
|
|
||||||
|
{/* ---- Welcome ---- */}
|
||||||
|
{currentStep.id === "welcome" && (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gold/10 border border-gold/20">
|
||||||
|
<Sparkles className="w-8 h-8 text-gold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Daily Briefing</h1>
|
||||||
|
<p className="text-sm text-base-400 mt-2">Einrichtungsassistent</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-base-400 leading-relaxed max-w-sm mx-auto">
|
||||||
|
Willkommen! In wenigen Schritten konfigurierst du dein Dashboard.
|
||||||
|
Zuerst ein Admin-Passwort, dann die gewünschten Integrationen.
|
||||||
|
Du kannst optionale Schritte überspringen.
|
||||||
|
</p>
|
||||||
|
<button onClick={next} className="wizard-btn-primary">
|
||||||
|
Einrichtung starten <ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Admin Password ---- */}
|
||||||
|
{currentStep.id === "admin" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepHeader icon={Lock} title="Admin-Passwort festlegen" desc="Dieses Passwort brauchst du für das Admin-Panel." />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField label="Passwort" description="Mindestens 6 Zeichen">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPw ? "text" : "password"}
|
||||||
|
value={adminPw}
|
||||||
|
onChange={(e) => setAdminPw(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
autoFocus
|
||||||
|
className="wizard-input pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPw(!showPw)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-base-500 hover:text-base-300"
|
||||||
|
>
|
||||||
|
{showPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Passwort bestätigen">
|
||||||
|
<input
|
||||||
|
type={showPw ? "text" : "password"}
|
||||||
|
value={adminPwConfirm}
|
||||||
|
onChange={(e) => setAdminPwConfirm(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="wizard-input"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<ErrorBox error={error} />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button onClick={prev} className="wizard-btn-secondary"><ArrowLeft className="w-4 h-4" /> Zurück</button>
|
||||||
|
<button onClick={handleAdminCreate} disabled={busy} className="wizard-btn-primary">
|
||||||
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Weiter <ArrowRight className="w-4 h-4" /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Weather ---- */}
|
||||||
|
{currentStep.id === "weather" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepHeader icon={Cloud} title="Wetter" desc="Standorte für die Wetteranzeige." />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField label="Primärer Standort">
|
||||||
|
<TextInput value={weatherCfg.location} onChange={(v) => setWeatherCfg({ ...weatherCfg, location: v })} placeholder="z.B. Berlin" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Sekundärer Standort" description="Optional — z.B. Urlaubsort">
|
||||||
|
<TextInput value={weatherCfg.location_secondary} onChange={(v) => setWeatherCfg({ ...weatherCfg, location_secondary: v })} placeholder="z.B. Mallorca" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<TestResultBox result={testResult} />
|
||||||
|
<ErrorBox error={error} />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button onClick={prev} className="wizard-btn-secondary"><ArrowLeft className="w-4 h-4" /> Zurück</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveIntegration("weather", weatherCfg, true)}
|
||||||
|
disabled={busy}
|
||||||
|
className="wizard-btn-primary"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Weiter <ArrowRight className="w-4 h-4" /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Home Assistant ---- */}
|
||||||
|
{currentStep.id === "ha" && (
|
||||||
|
<IntegrationStepLayout
|
||||||
|
icon={Home} title="Home Assistant" desc="Lichter, Sensoren, Rollos aus Home Assistant."
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={() => handleSaveIntegration("ha", haCfg, haEnabled)}
|
||||||
|
onSkip={() => skipIntegration("ha")}
|
||||||
|
onTest={() => handleTest("ha", haCfg)}
|
||||||
|
busy={busy} testing={testing} testResult={testResult} error={error}
|
||||||
|
enabled={haEnabled} onToggle={() => { setHaEnabled(!haEnabled); clearFeedback(); }}
|
||||||
|
>
|
||||||
|
<FormField label="URL" description="z.B. https://homeassistant.local:8123">
|
||||||
|
<TextInput value={haCfg.url} onChange={(v) => setHaCfg({ ...haCfg, url: v })} placeholder="https://..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Long-Lived Access Token">
|
||||||
|
<TextInput value={haCfg.token} onChange={(v) => setHaCfg({ ...haCfg, token: v })} type="password" placeholder="eyJ..." />
|
||||||
|
</FormField>
|
||||||
|
</IntegrationStepLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Vikunja ---- */}
|
||||||
|
{currentStep.id === "vikunja" && (
|
||||||
|
<IntegrationStepLayout
|
||||||
|
icon={ListTodo} title="Vikunja Tasks" desc="Aufgabenlisten aus Vikunja."
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={() => handleSaveIntegration("vikunja", {
|
||||||
|
...vikunjaCfg,
|
||||||
|
private_projects: parseIntList(vikunjaCfg.private_projects),
|
||||||
|
sams_projects: parseIntList(vikunjaCfg.sams_projects),
|
||||||
|
}, vikunjaEnabled)}
|
||||||
|
onSkip={() => skipIntegration("vikunja")}
|
||||||
|
onTest={() => handleTest("vikunja", vikunjaCfg)}
|
||||||
|
busy={busy} testing={testing} testResult={testResult} error={error}
|
||||||
|
enabled={vikunjaEnabled} onToggle={() => { setVikunjaEnabled(!vikunjaEnabled); clearFeedback(); }}
|
||||||
|
>
|
||||||
|
<FormField label="API URL" description="z.B. http://10.10.10.10:3456/api/v1">
|
||||||
|
<TextInput value={vikunjaCfg.url} onChange={(v) => setVikunjaCfg({ ...vikunjaCfg, url: v })} placeholder="http://..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="API Token">
|
||||||
|
<TextInput value={vikunjaCfg.token} onChange={(v) => setVikunjaCfg({ ...vikunjaCfg, token: v })} type="password" placeholder="tk_..." />
|
||||||
|
</FormField>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Private Projekte (IDs)" description="Komma-getrennt">
|
||||||
|
<TextInput value={vikunjaCfg.private_projects} onChange={(v) => setVikunjaCfg({ ...vikunjaCfg, private_projects: v })} placeholder="3,4" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Sam's Projekte (IDs)">
|
||||||
|
<TextInput value={vikunjaCfg.sams_projects} onChange={(v) => setVikunjaCfg({ ...vikunjaCfg, sams_projects: v })} placeholder="2,5" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</IntegrationStepLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Unraid ---- */}
|
||||||
|
{currentStep.id === "unraid" && (
|
||||||
|
<IntegrationStepLayout
|
||||||
|
icon={Server} title="Unraid Server" desc="Server-Status und Docker-Container."
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={() => handleSaveIntegration("unraid", { servers: unraidServers }, unraidEnabled)}
|
||||||
|
onSkip={() => skipIntegration("unraid")}
|
||||||
|
onTest={() => handleTest("unraid", { servers: unraidServers })}
|
||||||
|
busy={busy} testing={testing} testResult={testResult} error={error}
|
||||||
|
enabled={unraidEnabled} onToggle={() => { setUnraidEnabled(!unraidEnabled); clearFeedback(); }}
|
||||||
|
>
|
||||||
|
{unraidServers.map((srv, idx) => (
|
||||||
|
<div key={idx} className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.06] space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-slate-300">Server #{idx + 1}</span>
|
||||||
|
<button onClick={() => removeServer(idx)} className="p-1.5 rounded-lg text-slate-500 hover:text-red-400 hover:bg-red-500/10 transition-colors">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Name">
|
||||||
|
<TextInput value={srv.name} onChange={(v) => updateServer(idx, "name", v)} placeholder="z.B. Main Server" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Host">
|
||||||
|
<TextInput value={srv.host} onChange={(v) => updateServer(idx, "host", v)} placeholder="10.10.10.10" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="API Key">
|
||||||
|
<TextInput value={srv.api_key} onChange={(v) => updateServer(idx, "api_key", v)} type="password" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Port">
|
||||||
|
<NumberInput value={srv.port} onChange={(v) => updateServer(idx, "port", v)} min={1} max={65535} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addServer} className="flex items-center gap-2 w-full p-3 rounded-xl border border-dashed border-white/[0.1] text-xs text-slate-400 hover:text-white hover:border-white/[0.2] hover:bg-white/[0.02] transition-all">
|
||||||
|
<Plus className="w-4 h-4" /> Server hinzufügen
|
||||||
|
</button>
|
||||||
|
</IntegrationStepLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- MQTT ---- */}
|
||||||
|
{currentStep.id === "mqtt" && (
|
||||||
|
<IntegrationStepLayout
|
||||||
|
icon={Radio} title="MQTT Broker" desc="Live-Daten via MQTT."
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={() => handleSaveIntegration("mqtt", mqttCfg, mqttEnabled)}
|
||||||
|
onSkip={() => skipIntegration("mqtt")}
|
||||||
|
onTest={() => handleTest("mqtt", mqttCfg)}
|
||||||
|
busy={busy} testing={testing} testResult={testResult} error={error}
|
||||||
|
enabled={mqttEnabled} onToggle={() => { setMqttEnabled(!mqttEnabled); clearFeedback(); }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Host">
|
||||||
|
<TextInput value={mqttCfg.host} onChange={(v) => setMqttCfg({ ...mqttCfg, host: v })} placeholder="10.10.10.10" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Port">
|
||||||
|
<NumberInput value={mqttCfg.port} onChange={(v) => setMqttCfg({ ...mqttCfg, port: v })} min={1} max={65535} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField label="Benutzername" description="Leer = kein Auth">
|
||||||
|
<TextInput value={mqttCfg.username} onChange={(v) => setMqttCfg({ ...mqttCfg, username: v })} placeholder="Optional" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Passwort">
|
||||||
|
<TextInput value={mqttCfg.password} onChange={(v) => setMqttCfg({ ...mqttCfg, password: v })} type="password" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Client ID">
|
||||||
|
<TextInput value={mqttCfg.client_id} onChange={(v) => setMqttCfg({ ...mqttCfg, client_id: v })} placeholder="daily-briefing" />
|
||||||
|
</FormField>
|
||||||
|
</IntegrationStepLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- n8n ---- */}
|
||||||
|
{currentStep.id === "n8n" && (
|
||||||
|
<IntegrationStepLayout
|
||||||
|
icon={Webhook} title="n8n Webhooks" desc="Workflows und Webhooks via n8n triggern."
|
||||||
|
onPrev={prev}
|
||||||
|
onNext={() => handleSaveIntegration("n8n", n8nCfg, n8nEnabled)}
|
||||||
|
onSkip={() => skipIntegration("n8n")}
|
||||||
|
onTest={() => handleTest("n8n", n8nCfg)}
|
||||||
|
busy={busy} testing={testing} testResult={testResult} error={error}
|
||||||
|
enabled={n8nEnabled} onToggle={() => { setN8nEnabled(!n8nEnabled); clearFeedback(); }}
|
||||||
|
>
|
||||||
|
<FormField label="n8n URL" description="z.B. http://10.10.10.10:5678">
|
||||||
|
<TextInput value={n8nCfg.url} onChange={(v) => setN8nCfg({ ...n8nCfg, url: v })} placeholder="http://..." />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="API Key">
|
||||||
|
<TextInput value={n8nCfg.api_key} onChange={(v) => setN8nCfg({ ...n8nCfg, api_key: v })} type="password" placeholder="eyJ..." />
|
||||||
|
</FormField>
|
||||||
|
</IntegrationStepLayout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- News ---- */}
|
||||||
|
{currentStep.id === "news" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepHeader icon={Newspaper} title="News" desc="Nachrichten aus der Datenbank." />
|
||||||
|
<FormField label="Max. Alter (Stunden)" description="Ältere Artikel werden ausgeblendet">
|
||||||
|
<NumberInput value={newsCfg.max_age_hours} onChange={(v) => setNewsCfg({ max_age_hours: v })} min={1} max={168} />
|
||||||
|
</FormField>
|
||||||
|
<ErrorBox error={error} />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button onClick={prev} className="wizard-btn-secondary"><ArrowLeft className="w-4 h-4" /> Zurück</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveIntegration("news", newsCfg, true)}
|
||||||
|
disabled={busy}
|
||||||
|
className="wizard-btn-primary"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Weiter <ArrowRight className="w-4 h-4" /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Complete ---- */}
|
||||||
|
{currentStep.id === "complete" && (
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-sage/10 border border-sage/20">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-sage" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white">Setup abgeschlossen!</h2>
|
||||||
|
<p className="text-sm text-base-400 mt-2">Dein Dashboard ist bereit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="text-left space-y-2 p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||||
|
<p className="text-xs font-semibold text-base-300 uppercase tracking-wide mb-3">Konfiguriert</p>
|
||||||
|
{["weather", "ha", "vikunja", "unraid", "mqtt", "n8n", "news"].map((type) => {
|
||||||
|
const label: Record<string, string> = { weather: "Wetter", ha: "Home Assistant", vikunja: "Vikunja", unraid: "Unraid", mqtt: "MQTT", n8n: "n8n", news: "News" };
|
||||||
|
const isOn = type === "weather" || type === "news" || configured.has(type);
|
||||||
|
return (
|
||||||
|
<div key={type} className="flex items-center justify-between py-1.5">
|
||||||
|
<span className="text-sm text-base-300">{label[type]}</span>
|
||||||
|
<span className={`text-xs font-medium ${isOn ? "text-sage" : "text-base-600"}`}>
|
||||||
|
{isOn ? "Aktiv" : "Übersprungen"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ErrorBox error={error} />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button onClick={prev} className="wizard-btn-secondary"><ArrowLeft className="w-4 h-4" /> Zurück</button>
|
||||||
|
<button onClick={handleComplete} disabled={busy} className="wizard-btn-primary">
|
||||||
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Dashboard öffnen <ArrowRight className="w-4 h-4" /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Sub-components */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function StepHeader({ icon: Icon, title, desc }: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gold/10 border border-gold/20 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Icon className="w-5 h-5 text-gold" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white">{title}</h2>
|
||||||
|
<p className="text-xs text-base-400 mt-0.5">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBox({ error }: { error: string }) {
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestResultBox({ result }: { result: { success: boolean; message: string } | null }) {
|
||||||
|
if (!result) return null;
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 p-3 rounded-xl border ${
|
||||||
|
result.success
|
||||||
|
? "bg-sage/10 border-sage/20"
|
||||||
|
: "bg-red-500/10 border-red-500/20"
|
||||||
|
}`}>
|
||||||
|
{result.success ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-sage flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<p className={`text-xs ${result.success ? "text-sage" : "text-red-300"}`}>{result.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationStepLayout({
|
||||||
|
icon, title, desc, children,
|
||||||
|
onPrev, onNext, onSkip, onTest,
|
||||||
|
busy, testing, testResult, error,
|
||||||
|
enabled, onToggle,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onSkip: () => void;
|
||||||
|
onTest?: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
testing: boolean;
|
||||||
|
testResult: { success: boolean; message: string } | null;
|
||||||
|
error: string;
|
||||||
|
enabled: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StepHeader icon={icon} title={title} desc={desc} />
|
||||||
|
|
||||||
|
{/* Enable toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/[0.06]">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Integration aktivieren</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">{enabled ? "Aktiv" : "Deaktiviert"}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors ${enabled ? "bg-gold" : "bg-slate-700"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0"}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`space-y-4 transition-opacity ${enabled ? "opacity-100" : "opacity-40 pointer-events-none"}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TestResultBox result={testResult} />
|
||||||
|
<ErrorBox error={error} />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button onClick={onPrev} className="wizard-btn-secondary"><ArrowLeft className="w-4 h-4" /> Zurück</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onTest && enabled && (
|
||||||
|
<button onClick={onTest} disabled={testing} className="wizard-btn-test">
|
||||||
|
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : "Testen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onSkip} disabled={busy} className="wizard-btn-skip">
|
||||||
|
<SkipForward className="w-3.5 h-3.5" /> Überspringen
|
||||||
|
</button>
|
||||||
|
<button onClick={onNext} disabled={busy} className="wizard-btn-primary">
|
||||||
|
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <>Speichern <ArrowRight className="w-4 h-4" /></>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
web/src/setup/api.ts
Normal file
100
web/src/setup/api.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
let setupToken: string | null = null;
|
||||||
|
|
||||||
|
export function setSetupToken(token: string): void {
|
||||||
|
setupToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetupToken(): string | null {
|
||||||
|
return setupToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupFetch(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...((options.headers as Record<string, string>) || {}),
|
||||||
|
};
|
||||||
|
if (setupToken) {
|
||||||
|
headers["Authorization"] = `Bearer ${setupToken}`;
|
||||||
|
}
|
||||||
|
return fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Status ---
|
||||||
|
|
||||||
|
export interface SetupStatus {
|
||||||
|
setup_complete: boolean;
|
||||||
|
integrations: Array<{ type: string; name: string; enabled: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||||
|
const res = await fetch(`${API_BASE}/setup/status`);
|
||||||
|
if (!res.ok) throw new Error("Failed to check setup status");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin Creation ---
|
||||||
|
|
||||||
|
export async function createAdmin(
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; username: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/setup/admin`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || "Admin-Erstellung fehlgeschlagen");
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSetupToken(data.token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Integration Config ---
|
||||||
|
|
||||||
|
export async function saveIntegration(
|
||||||
|
type: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const res = await setupFetch(`/setup/integration/${type}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ config, enabled }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || "Speichern fehlgeschlagen");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testIntegrationConfig(
|
||||||
|
type: string,
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const res = await setupFetch(`/setup/integration/${type}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ config }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || "Test fehlgeschlagen");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Complete ---
|
||||||
|
|
||||||
|
export async function completeSetup(): Promise<void> {
|
||||||
|
const res = await setupFetch("/setup/complete", { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.detail || "Setup-Abschluss fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -175,6 +175,28 @@
|
||||||
.divider {
|
.divider {
|
||||||
@apply border-none h-px bg-base-300 my-6;
|
@apply border-none h-px bg-base-300 my-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admin glass card ─────────────────────────────────────── */
|
||||||
|
.glass-card {
|
||||||
|
@apply rounded-2xl bg-slate-950/80 border border-white/[0.08] backdrop-blur-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Setup Wizard Buttons ─────────────────────────────────── */
|
||||||
|
.wizard-btn-primary {
|
||||||
|
@apply flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-gold to-amber-600 text-sm font-semibold text-base-900 hover:from-amber-400 hover:to-amber-500 transition-all disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
.wizard-btn-secondary {
|
||||||
|
@apply flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-base-400 hover:text-white hover:border-white/[0.15] transition-all;
|
||||||
|
}
|
||||||
|
.wizard-btn-skip {
|
||||||
|
@apply flex items-center gap-1.5 px-4 py-2.5 rounded-xl text-xs text-base-500 hover:text-base-300 hover:bg-white/5 transition-all disabled:opacity-40;
|
||||||
|
}
|
||||||
|
.wizard-btn-test {
|
||||||
|
@apply px-4 py-2.5 rounded-xl border border-iris/30 text-xs font-medium text-iris hover:bg-iris/10 transition-all disabled:opacity-40;
|
||||||
|
}
|
||||||
|
.wizard-input {
|
||||||
|
@apply w-full px-3.5 py-2.5 rounded-xl bg-white/5 border border-white/[0.08] text-sm text-white placeholder-slate-600 focus:outline-none focus:border-gold/50 focus:ring-1 focus:ring-gold/20 transition-colors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue