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:
Sam 2026-03-02 16:06:10 +01:00
parent e25d055ba2
commit 6651bfaf60
9 changed files with 1042 additions and 34 deletions

View file

@ -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

View file

@ -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
View 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"}

View file

@ -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(
"=" * 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)) await settings_service.create_admin_user("admin", hash_password(admin_pw))
logger.info("Admin user seeded from ENV") 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."
)
# ---- 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:

View file

@ -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,
} }

View file

@ -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>

View 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
View 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");
}
}

View file

@ -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 {