diff --git a/docker-compose.yml b/docker-compose.yml index f13bab8..7d8483b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,37 +3,21 @@ services: build: . container_name: daily-briefing ports: - - "8080:8080" + - "9080:8080" environment: - # ── Required: Database (PostgreSQL) ── + # ── Required: Database (external PostgreSQL) ── - DB_HOST=10.10.10.10 - DB_PORT=5433 - DB_NAME=openclaw - DB_USER=sam - DB_PASSWORD=sam - # ── Required: Admin Panel ── - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin} - - JWT_SECRET=${JWT_SECRET:-} + # ── Optional: skip setup wizard by providing ADMIN_PASSWORD ── + # - ADMIN_PASSWORD=your-password + # - JWT_SECRET=optional-fixed-secret - # ── Seed Values (used on first start only, then DB takes over) ── - # Weather - - 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:-#} + # ── All integrations are configured via the web setup wizard ── + # Visit http://host:9080 on first start to complete setup. extra_hosts: - "host.docker.internal:host-gateway" restart: always diff --git a/server/main.py b/server/main.py index b7bb864..51578b9 100644 --- a/server/main.py +++ b/server/main.py @@ -112,8 +112,9 @@ app.add_middleware( ) # --- Register Routers --- -from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, tasks, weather # noqa: E402 +from server.routers import admin, auth, dashboard, homeassistant, mqtt, news, servers, setup, tasks, weather # noqa: E402 +app.include_router(setup.router) app.include_router(auth.router) app.include_router(admin.router) app.include_router(weather.router) @@ -128,6 +129,13 @@ app.include_router(dashboard.router) static_dir = Path(__file__).parent.parent / "static" if static_dir.is_dir(): # SPA fallback: serve index.html for any non-API path + @app.get("/setup{full_path:path}") + async def setup_spa_fallback(full_path: str = ""): + index = static_dir / "index.html" + if index.exists(): + return FileResponse(str(index)) + return {"error": "Frontend not built"} + @app.get("/admin/{full_path:path}") async def admin_spa_fallback(full_path: str): index = static_dir / "index.html" diff --git a/server/routers/setup.py b/server/routers/setup.py new file mode 100644 index 0000000..9aefdfc --- /dev/null +++ b/server/routers/setup.py @@ -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"} diff --git a/server/services/seed_service.py b/server/services/seed_service.py index 6cee3ba..d59d26a 100644 --- a/server/services/seed_service.py +++ b/server/services/seed_service.py @@ -22,17 +22,16 @@ async def seed_if_empty() -> None: user = await settings_service.get_admin_user() if user is None: admin_pw = os.getenv("ADMIN_PASSWORD", "") - if not admin_pw: - admin_pw = secrets.token_urlsafe(16) - logger.warning( - "=" * 60 + "\n" - " No ADMIN_PASSWORD set — generated: %s\n" - " Set ADMIN_PASSWORD env to use your own.\n" + - "=" * 60, - admin_pw, + if admin_pw: + # Explicit ENV password → seed admin user (backward compat) + await settings_service.create_admin_user("admin", hash_password(admin_pw)) + logger.info("Admin user seeded from ADMIN_PASSWORD env") + else: + # No password set → setup wizard will handle admin creation + logger.info( + "No ADMIN_PASSWORD set — setup wizard will handle first-time " + "configuration. Visit the web UI to complete setup." ) - await settings_service.create_admin_user("admin", hash_password(admin_pw)) - logger.info("Admin user seeded from ENV") # ---- Integrations ---- existing = await settings_service.get_integrations() @@ -103,6 +102,16 @@ async def seed_if_empty() -> None: "enabled": bool(os.getenv("MQTT_HOST")), "display_order": 5, }, + { + "type": "n8n", + "name": "n8n Webhooks", + "config": { + "url": os.getenv("N8N_URL", ""), + "api_key": os.getenv("N8N_API_KEY", ""), + }, + "enabled": bool(os.getenv("N8N_URL")), + "display_order": 6, + }, ] for seed in seed_integrations: diff --git a/server/services/test_connections.py b/server/services/test_connections.py index 804038f..c643f23 100644 --- a/server/services/test_connections.py +++ b/server/services/test_connections.py @@ -136,6 +136,27 @@ async def test_news_db(config: Dict[str, Any]) -> Dict[str, Any]: return {"success": False, "message": str(exc)} +async def test_n8n(config: Dict[str, Any]) -> Dict[str, Any]: + """Test n8n connection by fetching workflows.""" + url = config.get("url", "") + api_key = config.get("api_key", "") + if not url or not api_key: + return {"success": False, "message": "URL und API Key sind erforderlich"} + try: + base = url.rstrip("/") + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + r = await client.get( + f"{base}/api/v1/workflows", + headers={"X-N8N-API-KEY": api_key}, + ) + r.raise_for_status() + data = r.json() + count = len(data.get("data", [])) + return {"success": True, "message": f"Verbunden — {count} Workflows gefunden"} + except Exception as exc: + return {"success": False, "message": str(exc)} + + # Map integration type → test function TEST_FUNCTIONS = { "weather": test_weather, @@ -144,4 +165,5 @@ TEST_FUNCTIONS = { "unraid": test_unraid, "mqtt": test_mqtt, "news": test_news_db, + "n8n": test_n8n, } diff --git a/web/src/App.tsx b/web/src/App.tsx index 600ba83..4b30663 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,8 @@ 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 AdminLayout from "./admin/AdminLayout"; import LoginPage from "./admin/LoginPage"; @@ -12,6 +16,34 @@ import MqttSettings from "./admin/pages/MqttSettings"; import ChangePassword from "./admin/pages/ChangePassword"; export default function App() { + const [setupComplete, setSetupComplete] = useState(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 ( +
+ +
+ ); + } + + // Setup not complete: force wizard + if (!setupComplete) { + return ( + + } /> + } /> + + ); + } + + // Normal app return ( {/* Dashboard */} @@ -33,6 +65,9 @@ export default function App() { } /> + {/* Setup redirect (already complete) */} + } /> + {/* Catch all */} } /> diff --git a/web/src/setup/SetupWizard.tsx b/web/src/setup/SetupWizard.tsx new file mode 100644 index 0000000..9b0c2e7 --- /dev/null +++ b/web/src/setup/SetupWizard.tsx @@ -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([]); + 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>(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, 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) => { + 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> = { + 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 ( +
+ {/* Progress bar */} +
+
+ {STEPS.map((s, i) => { + const Icon = s.icon; + const done = i < step; + const active = i === step; + return ( +
+
+ {done ? : } +
+ + {s.label} + +
+ ); + })} +
+ {/* Progress line */} +
+
+
+
+ + {/* Content */} +
+
+ {/* Step card */} +
+ + {/* ---- Welcome ---- */} + {currentStep.id === "welcome" && ( +
+
+ +
+
+

Daily Briefing

+

Einrichtungsassistent

+
+

+ Willkommen! In wenigen Schritten konfigurierst du dein Dashboard. + Zuerst ein Admin-Passwort, dann die gewünschten Integrationen. + Du kannst optionale Schritte überspringen. +

+ +
+ )} + + {/* ---- Admin Password ---- */} + {currentStep.id === "admin" && ( +
+ +
+ +
+ setAdminPw(e.target.value)} + placeholder="••••••••" + autoFocus + className="wizard-input pr-10" + /> + +
+
+ + setAdminPwConfirm(e.target.value)} + placeholder="••••••••" + className="wizard-input" + /> + +
+ +
+ + +
+
+ )} + + {/* ---- Weather ---- */} + {currentStep.id === "weather" && ( +
+ +
+ + setWeatherCfg({ ...weatherCfg, location: v })} placeholder="z.B. Berlin" /> + + + setWeatherCfg({ ...weatherCfg, location_secondary: v })} placeholder="z.B. Mallorca" /> + +
+ + +
+ + +
+
+ )} + + {/* ---- Home Assistant ---- */} + {currentStep.id === "ha" && ( + 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(); }} + > + + setHaCfg({ ...haCfg, url: v })} placeholder="https://..." /> + + + setHaCfg({ ...haCfg, token: v })} type="password" placeholder="eyJ..." /> + + + )} + + {/* ---- Vikunja ---- */} + {currentStep.id === "vikunja" && ( + 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(); }} + > + + setVikunjaCfg({ ...vikunjaCfg, url: v })} placeholder="http://..." /> + + + setVikunjaCfg({ ...vikunjaCfg, token: v })} type="password" placeholder="tk_..." /> + +
+ + setVikunjaCfg({ ...vikunjaCfg, private_projects: v })} placeholder="3,4" /> + + + setVikunjaCfg({ ...vikunjaCfg, sams_projects: v })} placeholder="2,5" /> + +
+
+ )} + + {/* ---- Unraid ---- */} + {currentStep.id === "unraid" && ( + 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) => ( +
+
+ Server #{idx + 1} + +
+
+ + updateServer(idx, "name", v)} placeholder="z.B. Main Server" /> + + + updateServer(idx, "host", v)} placeholder="10.10.10.10" /> + + + updateServer(idx, "api_key", v)} type="password" /> + + + updateServer(idx, "port", v)} min={1} max={65535} /> + +
+
+ ))} + +
+ )} + + {/* ---- MQTT ---- */} + {currentStep.id === "mqtt" && ( + 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(); }} + > +
+ + setMqttCfg({ ...mqttCfg, host: v })} placeholder="10.10.10.10" /> + + + setMqttCfg({ ...mqttCfg, port: v })} min={1} max={65535} /> + +
+
+ + setMqttCfg({ ...mqttCfg, username: v })} placeholder="Optional" /> + + + setMqttCfg({ ...mqttCfg, password: v })} type="password" /> + +
+ + setMqttCfg({ ...mqttCfg, client_id: v })} placeholder="daily-briefing" /> + +
+ )} + + {/* ---- n8n ---- */} + {currentStep.id === "n8n" && ( + 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(); }} + > + + setN8nCfg({ ...n8nCfg, url: v })} placeholder="http://..." /> + + + setN8nCfg({ ...n8nCfg, api_key: v })} type="password" placeholder="eyJ..." /> + + + )} + + {/* ---- News ---- */} + {currentStep.id === "news" && ( +
+ + + setNewsCfg({ max_age_hours: v })} min={1} max={168} /> + + +
+ + +
+
+ )} + + {/* ---- Complete ---- */} + {currentStep.id === "complete" && ( +
+
+ +
+
+

Setup abgeschlossen!

+

Dein Dashboard ist bereit.

+
+ + {/* Summary */} +
+

Konfiguriert

+ {["weather", "ha", "vikunja", "unraid", "mqtt", "n8n", "news"].map((type) => { + const label: Record = { 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 ( +
+ {label[type]} + + {isOn ? "Aktiv" : "Übersprungen"} + +
+ ); + })} +
+ + +
+ + +
+
+ )} + +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Sub-components */ +/* ------------------------------------------------------------------ */ + +function StepHeader({ icon: Icon, title, desc }: { icon: React.ComponentType<{ className?: string }>; title: string; desc: string }) { + return ( +
+
+ +
+
+

{title}

+

{desc}

+
+
+ ); +} + +function ErrorBox({ error }: { error: string }) { + if (!error) return null; + return ( +
+ +

{error}

+
+ ); +} + +function TestResultBox({ result }: { result: { success: boolean; message: string } | null }) { + if (!result) return null; + return ( +
+ {result.success ? ( + + ) : ( + + )} +

{result.message}

+
+ ); +} + +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 ( +
+ + + {/* Enable toggle */} +
+
+

Integration aktivieren

+

{enabled ? "Aktiv" : "Deaktiviert"}

+
+ +
+ +
+ {children} +
+ + + + +
+ +
+ {onTest && enabled && ( + + )} + + +
+
+
+ ); +} diff --git a/web/src/setup/api.ts b/web/src/setup/api.ts new file mode 100644 index 0000000..5c849fb --- /dev/null +++ b/web/src/setup/api.ts @@ -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 { + const headers: Record = { + "Content-Type": "application/json", + ...((options.headers as Record) || {}), + }; + 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 { + 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, + enabled: boolean, +): Promise> { + 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, +): 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 { + 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"); + } +} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index b29ee8e..571c2df 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -175,6 +175,28 @@ .divider { @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 {