feat: TOTP 2FA + diverse Verbesserungen

2FA (TOTP):
- backend/api/auth.go: zweistufiger Login-Flow mit Pending-Token (5min),
  Endpunkte /2fa/validate, /2fa/setup, /2fa/confirm, /2fa/disable
- backend/db/users.go: GetUserByID, SetTOTPSecret, EnableTOTP, DisableTOTP
- backend/db/postgres.go: Migration totp_secret + totp_enabled Spalten
- backend/models/user.go: TOTPEnabled/TOTPSecret Felder, neue Request-Typen
- backend/main.go: neue Routen registriert (/2fa/validate ohne Auth, rest geschuetzt)
- backend/go.mod+sum: github.com/pquerna/otp hinzugefuegt
- frontend/src/pages/Login.jsx: zweistufiger Login (Passwort -> TOTP)
- frontend/src/pages/SettingsPage.jsx: TwoFactorSection mit Setup/Deaktivieren
- frontend/src/stores/auth.js: validateTOTP Action
- frontend/src/api/client.js: setupTOTP, confirmTOTP, disableTOTP, validateTOTP

Weitere Aenderungen (unstaged -> jetzt committed):
- frontend/src/components/ScriptModal.jsx: neu
- frontend/src/components/AgentPanel.jsx: Erweiterungen
- frontend/src/components/ProxmoxPanel.jsx: Erweiterungen
- agent-linux/ws/handler.go: Erweiterungen
- backend/api/scheduler.go + tasks.go: Erweiterungen
- backend/db/tasks.go + models/task.go: Erweiterungen
This commit is contained in:
cynfo3000 2026-03-08 19:17:43 +01:00
parent c76868e806
commit 10684dfc0a
19 changed files with 926 additions and 98 deletions

View File

@ -45,6 +45,8 @@ func (h *Handler) HandleCommand(msg Message) {
response = h.handleUpdateCheck(msg) response = h.handleUpdateCheck(msg)
case "update": case "update":
response = h.handleUpdate(msg) response = h.handleUpdate(msg)
case "zpoolscrub":
response = h.handleZpoolScrub(msg)
case "pty_start": case "pty_start":
response = h.handlePTYStart(msg) response = h.handlePTYStart(msg)
case "pty_stop": case "pty_stop":
@ -426,3 +428,56 @@ func (h *Handler) handleUpdate(msg Message) Message {
return response return response
} }
// handleZpoolScrub: alle ZFS-Pools scrubben
func (h *Handler) handleZpoolScrub(msg Message) Message {
response := Message{Type: "response", ID: msg.ID}
log.Println("ZFS Scrub gestartet (alle Pools)...")
// Alle Pools ermitteln
poolOutput, err := h.runCommand("zpool list -H -o name 2>&1", 30)
if err != nil || strings.TrimSpace(poolOutput) == "" {
response.Status = "error"
response.Error = "Keine ZFS-Pools gefunden oder zpool nicht verfügbar"
return response
}
pools := []string{}
for _, line := range strings.Split(strings.TrimSpace(poolOutput), "\n") {
name := strings.TrimSpace(line)
if name != "" {
pools = append(pools, name)
}
}
results := map[string]string{}
scrubErrors := 0
// Jeden Pool scrubben (nicht-blockierend: scrub startet sofort und läuft im Hintergrund)
for _, pool := range pools {
log.Printf("ZFS Scrub starte: %s", pool)
out, err := h.runCommand(fmt.Sprintf("zpool scrub %s 2>&1", pool), 60)
if err != nil {
results[pool] = fmt.Sprintf("Fehler: %v — %s", err, strings.TrimSpace(out))
scrubErrors++
} else {
results[pool] = "Scrub gestartet"
}
}
if scrubErrors > 0 {
response.Status = "error"
response.Error = fmt.Sprintf("%d Pool(s) konnten nicht gestartet werden", scrubErrors)
} else {
response.Status = "ok"
}
log.Printf("ZFS Scrub: %d Pool(s) gestartet, %d Fehler", len(pools), scrubErrors)
response.Data = map[string]interface{}{
"pools": pools,
"results": results,
"message": fmt.Sprintf("Scrub auf %d Pool(s) gestartet", len(pools)-scrubErrors),
}
return response
}

View File

@ -12,6 +12,7 @@ import (
"github.com/cynfo/rmm-backend/db" "github.com/cynfo/rmm-backend/db"
"github.com/cynfo/rmm-backend/models" "github.com/cynfo/rmm-backend/models"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -24,6 +25,7 @@ type JWTClaims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
Role string `json:"role"` Role string `json:"role"`
TOTPPending bool `json:"totp_pending,omitempty"` // temp token während 2FA-Schritt
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@ -70,6 +72,22 @@ func (a *AuthHandler) GenerateToken(user *models.User) (string, time.Time, error
return signed, expiresAt, err return signed, expiresAt, err
} }
// Kurzlebiger Token nur für den TOTP-Schritt (5 Minuten, kein echter API-Zugriff)
func (a *AuthHandler) generateTOTPPendingToken(user *models.User) (string, error) {
claims := JWTClaims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
TOTPPending: true,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(a.secret)
}
func (a *AuthHandler) ValidateToken(tokenStr string) (*JWTClaims, error) { func (a *AuthHandler) ValidateToken(tokenStr string) (*JWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@ -84,6 +102,10 @@ func (a *AuthHandler) ValidateToken(tokenStr string) (*JWTClaims, error) {
if !ok || !token.Valid { if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token") return nil, fmt.Errorf("invalid token")
} }
// Echte API-Calls mit Pending-Token ablehnen
if claims.TOTPPending {
return nil, fmt.Errorf("totp pending token not valid for api access")
}
return claims, nil return claims, nil
} }
@ -106,6 +128,22 @@ func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
// 2FA aktiv → Pending-Token zurückgeben
if user.TOTPEnabled {
pendingToken, err := a.generateTOTPPendingToken(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen")
return
}
go a.db.AddAuditLog(user.Username, "login_2fa_pending", "user", "", user.Username, "", r.RemoteAddr)
writeJSON(w, http.StatusOK, models.LoginResponse{
TOTPRequired: true,
TOTPToken: pendingToken,
})
return
}
// Kein 2FA → direkt JWT
token, expiresAt, err := a.GenerateToken(user) token, expiresAt, err := a.GenerateToken(user)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen") writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen")
@ -113,17 +151,178 @@ func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
} }
a.db.UpdateUserLastLogin(user.ID) a.db.UpdateUserLastLogin(user.ID)
// Audit log
go a.db.AddAuditLog(user.Username, "login", "user", "", user.Username, "", r.RemoteAddr) go a.db.AddAuditLog(user.Username, "login", "user", "", user.Username, "", r.RemoteAddr)
writeJSON(w, http.StatusOK, models.LoginResponse{ writeJSON(w, http.StatusOK, models.LoginResponse{
Token: token, Token: token,
ExpiresAt: expiresAt.Format(time.RFC3339), ExpiresAt: expiresAt.Format(time.RFC3339),
User: *user, User: user,
}) })
} }
// POST /api/v1/auth/2fa/validate — zweiter Schritt beim Login
func (a *AuthHandler) TOTPValidate(w http.ResponseWriter, r *http.Request) {
var req models.TOTPValidateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
return
}
// Pending-Token parsen (ohne ValidateToken, der lehnt Pending-Tokens ab)
jwtToken, err := jwt.ParseWithClaims(req.TOTPToken, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return a.secret, nil
})
if err != nil {
writeError(w, http.StatusUnauthorized, "Ungueltige Session")
return
}
claims, ok := jwtToken.Claims.(*JWTClaims)
if !ok || !jwtToken.Valid || !claims.TOTPPending {
writeError(w, http.StatusUnauthorized, "Ungueltige Session")
return
}
user, err := a.db.GetUserByID(claims.UserID)
if err != nil || user == nil || !user.TOTPEnabled {
writeError(w, http.StatusUnauthorized, "Ungueltige Session")
return
}
if !totp.Validate(req.Code, user.TOTPSecret) {
writeError(w, http.StatusUnauthorized, "Ungültiger 2FA-Code")
return
}
token, expiresAt, err := a.GenerateToken(user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token-Generierung fehlgeschlagen")
return
}
a.db.UpdateUserLastLogin(user.ID)
go a.db.AddAuditLog(user.Username, "login_2fa_success", "user", "", user.Username, "", r.RemoteAddr)
writeJSON(w, http.StatusOK, models.LoginResponse{
Token: token,
ExpiresAt: expiresAt.Format(time.RFC3339),
User: user,
})
}
// POST /api/v1/auth/2fa/setup — Secret generieren, QR-URI zurückgeben
func (a *AuthHandler) TOTPSetup(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*JWTClaims)
if !ok {
writeError(w, http.StatusUnauthorized, "Nicht authentifiziert")
return
}
user, err := a.db.GetUserByID(claims.UserID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "User nicht gefunden")
return
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "cynfo RMM",
AccountName: user.Username,
Period: 30,
Digits: 6,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "TOTP-Generierung fehlgeschlagen")
return
}
if err := a.db.SetTOTPSecret(user.ID, key.Secret()); err != nil {
writeError(w, http.StatusInternalServerError, "Speichern fehlgeschlagen")
return
}
writeJSON(w, http.StatusOK, models.TOTPSetupResponse{
Secret: key.Secret(),
URI: key.URL(),
})
}
// POST /api/v1/auth/2fa/confirm — ersten Code prüfen und 2FA aktivieren
func (a *AuthHandler) TOTPConfirm(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*JWTClaims)
if !ok {
writeError(w, http.StatusUnauthorized, "Nicht authentifiziert")
return
}
var req models.TOTPConfirmRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
return
}
user, err := a.db.GetUserByID(claims.UserID)
if err != nil || user == nil || user.TOTPSecret == "" {
writeError(w, http.StatusBadRequest, "Kein Setup gefunden — bitte zuerst /2fa/setup aufrufen")
return
}
if !totp.Validate(req.Code, user.TOTPSecret) {
writeError(w, http.StatusUnauthorized, "Ungültiger Code")
return
}
if err := a.db.EnableTOTP(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Aktivierung fehlgeschlagen")
return
}
go a.db.AddAuditLog(claims.Username, "2fa_enabled", "user", "", claims.Username, "", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// POST /api/v1/auth/2fa/disable — 2FA deaktivieren (Passwort + aktueller Code nötig)
func (a *AuthHandler) TOTPDisable(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*JWTClaims)
if !ok {
writeError(w, http.StatusUnauthorized, "Nicht authentifiziert")
return
}
var req models.TOTPDisableRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
return
}
user, err := a.db.GetUserByID(claims.UserID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "User nicht gefunden")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
writeError(w, http.StatusUnauthorized, "Falsches Passwort")
return
}
if user.TOTPEnabled {
if !totp.Validate(req.Code, user.TOTPSecret) {
writeError(w, http.StatusUnauthorized, "Ungültiger 2FA-Code")
return
}
}
if err := a.db.DisableTOTP(user.ID); err != nil {
writeError(w, http.StatusInternalServerError, "Deaktivierung fehlgeschlagen")
return
}
go a.db.AddAuditLog(claims.Username, "2fa_disabled", "user", "", claims.Username, "", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// GET /api/v1/auth/me // GET /api/v1/auth/me
func (a *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { func (a *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
claims, ok := r.Context().Value(UserContextKey).(*JWTClaims) claims, ok := r.Context().Value(UserContextKey).(*JWTClaims)
@ -144,4 +343,5 @@ func (a *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
// SetupAuthRoutes registriert Auth-Routes (ohne Auth-Middleware) // SetupAuthRoutes registriert Auth-Routes (ohne Auth-Middleware)
func (a *AuthHandler) SetupAuthRoutes(mux *http.ServeMux) { func (a *AuthHandler) SetupAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/auth/login", a.Login) mux.HandleFunc("POST /api/v1/auth/login", a.Login)
mux.HandleFunc("POST /api/v1/auth/2fa/validate", a.TOTPValidate)
} }

View File

@ -63,6 +63,17 @@ func (s *TaskScheduler) executeTask(task models.Task) {
result, taskErr = s.executeBackup(task.AgentID) result, taskErr = s.executeBackup(task.AgentID)
case "update": case "update":
result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout, task.SecurityOnly) result, taskErr = s.executeUpdateWithHealthCheck(task.AgentID, task.Reboot, task.HealthCheck, task.HealthCheckTimeout, task.SecurityOnly)
case "zpoolscrub":
result, taskErr = s.executeSimpleCommand(task.AgentID, "zpoolscrub", map[string]interface{}{})
case "script":
timeout := task.ScriptTimeout
if timeout <= 0 {
timeout = 300
}
result, taskErr = s.executeSimpleCommand(task.AgentID, "exec", map[string]interface{}{
"command": task.Script,
"timeout": timeout,
})
default: default:
taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action) taskErr = fmt.Errorf("unbekannte Aktion: %s", task.Action)
} }
@ -106,6 +117,36 @@ func (s *TaskScheduler) executeTask(task models.Task) {
} }
} }
// executeSimpleCommand schickt einen beliebigen Command an den Agenten und wartet auf Antwort
func (s *TaskScheduler) executeSimpleCommand(agentID, command string, params map[string]interface{}) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden")
}
cmdID := generateID()
cmd := map[string]interface{}{
"type": "command",
"id": cmdID,
"command": command,
"params": params,
}
responseCh := s.hub.RegisterResponseWaiter(cmdID)
defer s.hub.UnregisterResponseWaiter(cmdID)
cmdJSON, _ := json.Marshal(cmd)
if err := s.hub.SendToAgent(agentID, cmdJSON); err != nil {
return nil, fmt.Errorf("Senden fehlgeschlagen: %w", err)
}
select {
case resp := <-responseCh:
if status, _ := resp["status"].(string); status == "error" {
errMsg, _ := resp["error"].(string)
return resp["data"], fmt.Errorf("%s", errMsg)
}
return resp["data"], nil
case <-time.After(120 * time.Second):
return nil, fmt.Errorf("Timeout nach 120s")
}
}
func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) { func (s *TaskScheduler) executeBackup(agentID string) (interface{}, error) {
if !s.hub.IsAgentConnected(agentID) { if !s.hub.IsAgentConnected(agentID) {
return nil, fmt.Errorf("Agent nicht verbunden") return nil, fmt.Errorf("Agent nicht verbunden")

View File

@ -80,6 +80,8 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
HealthCheck bool `json:"health_check"` HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"` HealthCheckTimeout int `json:"health_check_timeout"`
SecurityOnly bool `json:"security_only"` SecurityOnly bool `json:"security_only"`
Script string `json:"script"`
ScriptTimeout int `json:"script_timeout"`
WebhookURL string `json:"webhook_url"` WebhookURL string `json:"webhook_url"`
Active *bool `json:"active"` Active *bool `json:"active"`
} }
@ -93,8 +95,12 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
return return
} }
if req.Action != "backup" && req.Action != "update" { if req.Action != "backup" && req.Action != "update" && req.Action != "zpoolscrub" && req.Action != "script" {
writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update)") writeError(w, http.StatusBadRequest, "Ungueltige Aktion (backup, update, zpoolscrub, script)")
return
}
if req.Action == "script" && req.Script == "" {
writeError(w, http.StatusBadRequest, "Skript-Inhalt darf nicht leer sein")
return return
} }
@ -119,6 +125,7 @@ func (h *Handler) createTask(hub *ws.Hub) http.HandlerFunc {
req.Name, req.ScheduleType, req.ScheduleTime, req.Name, req.ScheduleType, req.ScheduleTime,
req.ScheduleWeekday, req.ScheduleMonthday, req.ScheduleWeekday, req.ScheduleMonthday,
req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.SecurityOnly, active, req.Reboot, req.HealthCheck, req.HealthCheckTimeout, req.SecurityOnly, active,
req.Script, req.ScriptTimeout,
) )
if err != nil { if err != nil {
log.Printf("Task erstellen fehlgeschlagen: %v", err) log.Printf("Task erstellen fehlgeschlagen: %v", err)

View File

@ -128,6 +128,10 @@ func (d *Database) migrate() error {
return fmt.Errorf("Schema anlegen: %w", err) return fmt.Errorf("Schema anlegen: %w", err)
} }
// TOTP 2FA Spalten
d.db.Exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret TEXT")
d.db.Exec("ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false")
// Agents um customer_id erweitern // Agents um customer_id erweitern
d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id)") d.db.Exec("ALTER TABLE agents ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id)")
d.db.Exec("CREATE INDEX IF NOT EXISTS idx_agents_customer ON agents(customer_id)") d.db.Exec("CREATE INDEX IF NOT EXISTS idx_agents_customer ON agents(customer_id)")
@ -266,6 +270,8 @@ func (d *Database) migrate() error {
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_run TIMESTAMPTZ`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_run TIMESTAMPTZ`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_run TIMESTAMPTZ`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_run TIMESTAMPTZ`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS run_count INT DEFAULT 0`) d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS run_count INT DEFAULT 0`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script TEXT`)
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`) d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
// Retention Policy (90 Tage) // Retention Policy (90 Tage)

View File

@ -62,9 +62,10 @@ func CalculateNextRun(scheduleType, scheduleTime string, weekday, monthday *int,
func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string, func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdBy string,
name, scheduleType, scheduleTime string, weekday, monthday *int, name, scheduleType, scheduleTime string, weekday, monthday *int,
reboot, healthCheck bool, healthCheckTimeout int, securityOnly bool, active bool) (*models.Task, error) { reboot, healthCheck bool, healthCheckTimeout int, securityOnly bool, active bool,
script string, scriptTimeout int) (*models.Task, error) {
validActions := map[string]bool{"backup": true, "update": true} validActions := map[string]bool{"backup": true, "update": true, "zpoolscrub": true, "script": true}
if !validActions[action] { if !validActions[action] {
return nil, fmt.Errorf("ungueltige Aktion: %s", action) return nil, fmt.Errorf("ungueltige Aktion: %s", action)
} }
@ -103,16 +104,22 @@ func (d *Database) CreateTask(agentID, action, scheduledAt, webhookURL, createdB
nextRun = CalculateNextRun(scheduleType, scheduleTime, weekday, monthday, now) nextRun = CalculateNextRun(scheduleType, scheduleTime, weekday, monthday, now)
} }
if scriptTimeout <= 0 {
scriptTimeout = 300
}
var taskID int var taskID int
err := d.db.QueryRow(` err := d.db.QueryRow(`
INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by, INSERT INTO tasks (agent_id, name, action, scheduled_at, webhook_url, created_by,
schedule_type, schedule_time, schedule_weekday, schedule_monthday, schedule_type, schedule_time, schedule_weekday, schedule_monthday,
reboot, health_check, health_check_timeout, security_only, active, next_run) reboot, health_check, health_check_timeout, security_only, active, next_run,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) script, script_timeout)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
RETURNING id RETURNING id
`, agentID, name, action, schedTime, webhookURL, createdBy, `, agentID, name, action, schedTime, webhookURL, createdBy,
scheduleType, scheduleTime, weekday, monthday, scheduleType, scheduleTime, weekday, monthday,
reboot, healthCheck, healthCheckTimeout, securityOnly, active, nextRun).Scan(&taskID) reboot, healthCheck, healthCheckTimeout, securityOnly, active, nextRun,
script, scriptTimeout).Scan(&taskID)
if err != nil { if err != nil {
return nil, fmt.Errorf("Task erstellen: %w", err) return nil, fmt.Errorf("Task erstellen: %w", err)
} }
@ -130,7 +137,8 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
var name, scheduleType, scheduleTime sql.NullString var name, scheduleType, scheduleTime sql.NullString
var scheduleWeekday, scheduleMonthday sql.NullInt32 var scheduleWeekday, scheduleMonthday sql.NullInt32
var reboot, healthCheck, securityOnly, active sql.NullBool var reboot, healthCheck, securityOnly, active sql.NullBool
var healthCheckTimeout, runCount sql.NullInt32 var healthCheckTimeout, runCount, scriptTimeout sql.NullInt32
var script sql.NullString
err := scan( err := scan(
&t.ID, &t.AgentID, &agentName, &customerName, &customerNumber, &t.ID, &t.AgentID, &agentName, &customerName, &customerNumber,
@ -139,6 +147,7 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
&createdBy, &createdAt, &createdBy, &createdAt,
&scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday, &scheduleType, &scheduleTime, &scheduleWeekday, &scheduleMonthday,
&reboot, &healthCheck, &healthCheckTimeout, &securityOnly, &active, &lastRun, &nextRun, &runCount, &reboot, &healthCheck, &healthCheckTimeout, &securityOnly, &active, &lastRun, &nextRun, &runCount,
&script, &scriptTimeout,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -171,6 +180,11 @@ func scanTask(scan func(dest ...interface{}) error) (*models.Task, error) {
t.HealthCheckTimeout = int(healthCheckTimeout.Int32) t.HealthCheckTimeout = int(healthCheckTimeout.Int32)
} }
t.SecurityOnly = securityOnly.Valid && securityOnly.Bool t.SecurityOnly = securityOnly.Valid && securityOnly.Bool
t.Script = script.String
t.ScriptTimeout = 300
if scriptTimeout.Valid && scriptTimeout.Int32 > 0 {
t.ScriptTimeout = int(scriptTimeout.Int32)
}
t.Active = !active.Valid || active.Bool t.Active = !active.Valid || active.Bool
t.RunCount = int(runCount.Int32) t.RunCount = int(runCount.Int32)
@ -215,7 +229,8 @@ const taskSelectCols = `t.id, t.agent_id, COALESCE(a.name, ''), COALESCE(c.name,
t.result, t.webhook_url, t.webhook_sent, t.webhook_response, t.result, t.webhook_url, t.webhook_sent, t.webhook_response,
t.created_by, t.created_at, t.created_by, t.created_at,
t.schedule_type, t.schedule_time, t.schedule_weekday, t.schedule_monthday, t.schedule_type, t.schedule_time, t.schedule_weekday, t.schedule_monthday,
t.reboot, t.health_check, t.health_check_timeout, COALESCE(t.security_only, FALSE), t.active, t.last_run, t.next_run, t.run_count` t.reboot, t.health_check, t.health_check_timeout, COALESCE(t.security_only, FALSE), t.active, t.last_run, t.next_run, t.run_count,
COALESCE(t.script, ''), COALESCE(t.script_timeout, 300)`
const taskFromJoin = `FROM tasks t const taskFromJoin = `FROM tasks t
LEFT JOIN agents a ON a.id = t.agent_id LEFT JOIN agents a ON a.id = t.agent_id

View File

@ -27,10 +27,11 @@ func (d *Database) CreateUser(username, password, displayName, role string) (*mo
func (d *Database) GetUserByUsername(username string) (*models.User, error) { func (d *Database) GetUserByUsername(username string) (*models.User, error) {
var u models.User var u models.User
var lastLogin sql.NullTime var lastLogin sql.NullTime
var totpSecret sql.NullString
err := d.db.QueryRow( err := d.db.QueryRow(
"SELECT id, username, password_hash, display_name, role, created_at, last_login FROM users WHERE username = $1", "SELECT id, username, password_hash, display_name, role, totp_enabled, totp_secret, created_at, last_login FROM users WHERE username = $1",
username, username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.DisplayName, &u.Role, &u.CreatedAt, &lastLogin) ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.DisplayName, &u.Role, &u.TOTPEnabled, &totpSecret, &u.CreatedAt, &lastLogin)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -40,9 +41,50 @@ func (d *Database) GetUserByUsername(username string) (*models.User, error) {
if lastLogin.Valid { if lastLogin.Valid {
u.LastLogin = &lastLogin.Time u.LastLogin = &lastLogin.Time
} }
if totpSecret.Valid {
u.TOTPSecret = totpSecret.String
}
return &u, nil return &u, nil
} }
func (d *Database) GetUserByID(id int) (*models.User, error) {
var u models.User
var lastLogin sql.NullTime
var totpSecret sql.NullString
err := d.db.QueryRow(
"SELECT id, username, password_hash, display_name, role, totp_enabled, totp_secret, created_at, last_login FROM users WHERE id = $1",
id,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.DisplayName, &u.Role, &u.TOTPEnabled, &totpSecret, &u.CreatedAt, &lastLogin)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if lastLogin.Valid {
u.LastLogin = &lastLogin.Time
}
if totpSecret.Valid {
u.TOTPSecret = totpSecret.String
}
return &u, nil
}
func (d *Database) SetTOTPSecret(userID int, secret string) error {
_, err := d.db.Exec("UPDATE users SET totp_secret = $1, totp_enabled = false WHERE id = $2", secret, userID)
return err
}
func (d *Database) EnableTOTP(userID int) error {
_, err := d.db.Exec("UPDATE users SET totp_enabled = true WHERE id = $1", userID)
return err
}
func (d *Database) DisableTOTP(userID int) error {
_, err := d.db.Exec("UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = $1", userID)
return err
}
func (d *Database) GetUsers() ([]models.User, error) { func (d *Database) GetUsers() ([]models.User, error) {
rows, err := d.db.Query("SELECT id, username, display_name, role, created_at, last_login FROM users ORDER BY username") rows, err := d.db.Query("SELECT id, username, display_name, role, created_at, last_login FROM users ORDER BY username")
if err != nil { if err != nil {

View File

@ -9,11 +9,13 @@ require (
) )
require ( require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect

View File

@ -1,3 +1,5 @@
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -20,6 +22,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@ -84,9 +84,15 @@ func main() {
// GET /api/v1/auth/me braucht JWT // GET /api/v1/auth/me braucht JWT
protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me) protectedMux.HandleFunc("GET /api/v1/auth/me", authHandler.Me)
// Geschuetzte 2FA-Routen
protectedMux.HandleFunc("POST /api/v1/auth/2fa/setup", authHandler.TOTPSetup)
protectedMux.HandleFunc("POST /api/v1/auth/2fa/confirm", authHandler.TOTPConfirm)
protectedMux.HandleFunc("POST /api/v1/auth/2fa/disable", authHandler.TOTPDisable)
// Combined router: auth routes ungeschuetzt, rest mit DB-basierter Auth // Combined router: auth routes ungeschuetzt, rest mit DB-basierter Auth
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("POST /api/v1/auth/login", authMux) mux.Handle("POST /api/v1/auth/login", authMux)
mux.Handle("POST /api/v1/auth/2fa/validate", authMux) // 2FA-Schritt 2 ebenfalls ohne JWT
mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux)) mux.Handle("/", api.CombinedAuthWithCache(apiKeyCache, authHandler, protectedMux))
// Middleware-Chain: CORS -> Logging -> Handler // Middleware-Chain: CORS -> Logging -> Handler

View File

@ -21,6 +21,8 @@ type Task struct {
HealthCheck bool `json:"health_check"` HealthCheck bool `json:"health_check"`
HealthCheckTimeout int `json:"health_check_timeout"` HealthCheckTimeout int `json:"health_check_timeout"`
SecurityOnly bool `json:"security_only,omitempty"` SecurityOnly bool `json:"security_only,omitempty"`
Script string `json:"script,omitempty"`
ScriptTimeout int `json:"script_timeout,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"` WebhookURL string `json:"webhook_url,omitempty"`
WebhookSent bool `json:"webhook_sent"` WebhookSent bool `json:"webhook_sent"`
WebhookResponse string `json:"webhook_response,omitempty"` WebhookResponse string `json:"webhook_response,omitempty"`

View File

@ -8,6 +8,8 @@ type User struct {
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Role string `json:"role"` Role string `json:"role"`
TOTPEnabled bool `json:"totp_enabled"`
TOTPSecret string `json:"-"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastLogin *time.Time `json:"last_login,omitempty"` LastLogin *time.Time `json:"last_login,omitempty"`
} }
@ -18,7 +20,28 @@ type LoginRequest struct {
} }
type LoginResponse struct { type LoginResponse struct {
Token string `json:"token"` Token string `json:"token,omitempty"`
ExpiresAt string `json:"expires_at"` ExpiresAt string `json:"expires_at,omitempty"`
User User `json:"user"` User *User `json:"user,omitempty"`
TOTPRequired bool `json:"totp_required,omitempty"`
TOTPToken string `json:"totp_token,omitempty"`
}
type TOTPValidateRequest struct {
TOTPToken string `json:"totp_token"`
Code string `json:"code"`
}
type TOTPSetupResponse struct {
Secret string `json:"secret"`
URI string `json:"uri"`
}
type TOTPConfirmRequest struct {
Code string `json:"code"`
}
type TOTPDisableRequest struct {
Password string `json:"password"`
Code string `json:"code"`
} }

View File

@ -68,10 +68,28 @@ class ApiClient {
// Auth // Auth
async login(username, password) { async login(username, password) {
const data = await this.post('/api/v1/auth/login', { username, password }) const data = await this.post('/api/v1/auth/login', { username, password })
this.setToken(data.token) if (data.token) this.setToken(data.token)
return data return data
} }
async validateTOTP(totpToken, code) {
const data = await this.post('/api/v1/auth/2fa/validate', { totp_token: totpToken, code })
if (data.token) this.setToken(data.token)
return data
}
async setupTOTP() {
return this.post('/api/v1/auth/2fa/setup')
}
async confirmTOTP(code) {
return this.post('/api/v1/auth/2fa/confirm', { code })
}
async disableTOTP(password, code) {
return this.post('/api/v1/auth/2fa/disable', { password, code })
}
logout() { logout() {
this.setToken(null) this.setToken(null)
} }

View File

@ -4,10 +4,11 @@ import StatusBadge from './StatusBadge'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import TerminalModal from './TerminalModal' import TerminalModal from './TerminalModal'
import ScriptModal from './ScriptModal'
import { import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route,
Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban, Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, Ban,
FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, Pencil, FileKey, UserCheck, Database, CalendarClock, Cable, Settings, Server, GitBranch, Play, Power, ChevronDown, ChevronRight, Pencil, FileCode,
} from 'lucide-react' } from 'lucide-react'
// Two-level navigation structure (DATAZONE style) // Two-level navigation structure (DATAZONE style)
@ -909,6 +910,7 @@ function TunnelTab({ agentId, agent }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const [sshCopied, setSshCopied] = useState(null) const [sshCopied, setSshCopied] = useState(null)
const [terminalOpen, setTerminalOpen] = useState(false) const [terminalOpen, setTerminalOpen] = useState(false)
const [scriptOpen, setScriptOpen] = useState(false)
const backendHost = useSettingsStore.getState().getBackendHost() const backendHost = useSettingsStore.getState().getBackendHost()
@ -981,6 +983,9 @@ function TunnelTab({ agentId, agent }) {
{terminalOpen && ( {terminalOpen && (
<TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} /> <TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} />
)} )}
{scriptOpen && (
<ScriptModal agentId={agentId} agentName={agent?.name} onClose={() => setScriptOpen(false)} />
)}
{/* Quick Buttons */} {/* Quick Buttons */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@ -1008,6 +1013,13 @@ function TunnelTab({ agentId, agent }) {
<Terminal className="w-4 h-4" /> <Terminal className="w-4 h-4" />
Web Terminal Web Terminal
</button> </button>
<button
onClick={() => setScriptOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-700 hover:bg-purple-600 rounded text-sm text-white transition-colors"
>
<FileCode className="w-4 h-4" />
Script
</button>
<button <button
onClick={() => setCustomOpen(!customOpen)} onClick={() => setCustomOpen(!customOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"

View File

@ -3,11 +3,12 @@ import api from '../api/client'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import StatusBadge from './StatusBadge' import StatusBadge from './StatusBadge'
import TerminalModal from './TerminalModal' import TerminalModal from './TerminalModal'
import ScriptModal from './ScriptModal'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import { import {
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check, Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, MonitorSmartphone, Terminal, Cable, ExternalLink, Plus, Unplug, Search, Shield, Play, FileCode,
} from 'lucide-react' } from 'lucide-react'
const buildTabs = (hasPBS) => [ const buildTabs = (hasPBS) => [
@ -252,6 +253,7 @@ function LinuxTasksTab({ agentId, agentName }) {
scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1, scheduled_at: '', schedule_weekday: 1, schedule_monthday: 1,
reboot: true, health_check: true, health_check_timeout: 600, reboot: true, health_check: true, health_check_timeout: 600,
security_only: false, webhook_url: '', active: true, security_only: false, webhook_url: '', active: true,
script: '', script_timeout: 300,
} }
const [form, setForm] = useState(defaultForm) const [form, setForm] = useState(defaultForm)
@ -261,6 +263,9 @@ function LinuxTasksTab({ agentId, agentName }) {
{ label: 'Wöchentlich nur Security', form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly', schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true, health_check_timeout: 300, security_only: true, active: true } }, { label: 'Wöchentlich nur Security', form: { name: 'Wöchentliche Security-Updates', action: 'update', schedule_type: 'weekly', schedule_time: '03:00', schedule_weekday: 3, reboot: false, health_check: true, health_check_timeout: 300, security_only: true, active: true } },
{ label: 'Täglich Security (kein Reboot)', form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily', schedule_time: '04:00', reboot: false, health_check: false, security_only: true, active: true } }, { label: 'Täglich Security (kein Reboot)', form: { name: 'Tägliche Security-Updates', action: 'update', schedule_type: 'daily', schedule_time: '04:00', reboot: false, health_check: false, security_only: true, active: true } },
{ label: 'Update + Webhook', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, security_only: false, webhook_url: '', active: true } }, { label: 'Update + Webhook', form: { name: 'Monatliches Update + Servicebericht', action: 'update', schedule_type: 'monthly', schedule_time: '02:00', schedule_monthday: 1, reboot: true, health_check: true, health_check_timeout: 600, security_only: false, webhook_url: '', active: true } },
{ label: 'ZFS Scrub (monatlich)', form: { name: 'Monatlicher ZFS Pool Scrub', action: 'zpoolscrub', schedule_type: 'monthly', schedule_time: '01:37', schedule_monthday: 20, reboot: false, health_check: false, active: true } },
{ label: 'ZFS Scrub (wöchentlich)', form: { name: 'Wöchentlicher ZFS Pool Scrub', action: 'zpoolscrub', schedule_type: 'weekly', schedule_time: '01:00', schedule_weekday: 0, reboot: false, health_check: false, active: true } },
{ label: 'Custom Script', form: { name: 'Benutzerdefiniertes Script', action: 'script', schedule_type: 'monthly', schedule_time: '03:00', schedule_monthday: 1, reboot: false, health_check: false, script: '#!/bin/bash\n\n', script_timeout: 300, active: true } },
] ]
const loadTasks = () => { const loadTasks = () => {
@ -281,6 +286,8 @@ function LinuxTasksTab({ agentId, agentName }) {
reboot: form.reboot, health_check: form.health_check, reboot: form.reboot, health_check: form.health_check,
health_check_timeout: parseInt(form.health_check_timeout) || 600, health_check_timeout: parseInt(form.health_check_timeout) || 600,
security_only: form.security_only || false, security_only: form.security_only || false,
script: form.script || '',
script_timeout: parseInt(form.script_timeout) || 300,
} }
if (form.schedule_type === 'once' && form.scheduled_at) data.scheduled_at = new Date(form.scheduled_at).toISOString() if (form.schedule_type === 'once' && form.scheduled_at) data.scheduled_at = new Date(form.scheduled_at).toISOString()
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday) if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
@ -298,6 +305,7 @@ function LinuxTasksTab({ agentId, agentName }) {
active: form.active, reboot: form.reboot, health_check: form.health_check, active: form.active, reboot: form.reboot, health_check: form.health_check,
health_check_timeout: parseInt(form.health_check_timeout) || 600, health_check_timeout: parseInt(form.health_check_timeout) || 600,
security_only: form.security_only || false, webhook_url: form.webhook_url, security_only: form.security_only || false, webhook_url: form.webhook_url,
script: form.script || '', script_timeout: parseInt(form.script_timeout) || 300,
} }
if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday) if (form.schedule_type === 'weekly') data.schedule_weekday = parseInt(form.schedule_weekday)
if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday) if (form.schedule_type === 'monthly') data.schedule_monthday = parseInt(form.schedule_monthday)
@ -394,7 +402,39 @@ function LinuxTasksTab({ agentId, agentName }) {
</div> </div>
)} )}
{/* Update-Optionen */} {/* Aktion wählen */}
<div>
<label className={labelCls}>Aktion *</label>
<select value={form.action} onChange={e => setForm({...form, action: e.target.value})} className={inputCls} disabled={!!editTask}>
<option value="update">Update (apt-get)</option>
<option value="zpoolscrub">ZFS Pool Scrub</option>
<option value="script">Custom Script</option>
</select>
</div>
{/* Script-Eingabe (nur bei action=script) */}
{form.action === 'script' && (
<div className="space-y-2">
<div>
<label className={labelCls}>Script-Inhalt *</label>
<textarea
value={form.script}
onChange={e => setForm({...form, script: e.target.value})}
rows={8}
spellCheck={false}
placeholder={'#!/bin/bash\n\n# Script hier eingeben...'}
className="w-full bg-gray-900 border border-gray-600 rounded px-3 py-2 text-xs font-mono text-gray-200 focus:outline-none focus:border-orange-500 resize-y"
/>
</div>
<div>
<label className={labelCls}>Timeout (Sekunden)</label>
<input type="number" value={form.script_timeout} onChange={e => setForm({...form, script_timeout: e.target.value})} className={inputCls + ' max-w-[160px]'} />
</div>
</div>
)}
{/* Update-Optionen (nur bei action=update) */}
{form.action === 'update' && (
<div className="space-y-2 pl-1"> <div className="space-y-2 pl-1">
<label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" checked={form.security_only || false} onChange={e => setForm({...form, security_only: e.target.checked})} className="accent-orange-500" /> <input type="checkbox" checked={form.security_only || false} onChange={e => setForm({...form, security_only: e.target.checked})} className="accent-orange-500" />
@ -415,6 +455,7 @@ function LinuxTasksTab({ agentId, agentName }) {
</div> </div>
)} )}
</div> </div>
)}
<div> <div>
<label className={labelCls}>Webhook-URL (optional)</label> <label className={labelCls}>Webhook-URL (optional)</label>
@ -473,7 +514,7 @@ function LinuxTasksTab({ agentId, agentName }) {
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex gap-1"> <div className="flex gap-1">
<button onClick={() => api.runTaskNow(t.id).then(() => setTimeout(loadTasks, 1500))} title="Jetzt ausführen" className="p-1 text-gray-500 hover:text-orange-400"><Download className="w-3.5 h-3.5" /></button> <button onClick={() => api.runTaskNow(t.id).then(() => setTimeout(loadTasks, 1500))} title="Jetzt ausführen" className="p-1 text-gray-500 hover:text-orange-400"><Download className="w-3.5 h-3.5" /></button>
<button onClick={() => { setEditTask(t); setForm({ name: t.name, action: t.action, schedule_type: t.schedule_type || 'once', schedule_time: t.schedule_time || '02:00', scheduled_at: '', schedule_weekday: t.schedule_weekday ?? 1, schedule_monthday: t.schedule_monthday ?? 1, reboot: t.reboot || false, health_check: t.health_check || false, health_check_timeout: t.health_check_timeout || 600, security_only: t.security_only || false, webhook_url: t.webhook_url || '', active: t.active !== false }); setShowDialog(true) }} title="Bearbeiten" className="p-1 text-gray-500 hover:text-blue-400"><Settings className="w-3.5 h-3.5" /></button> <button onClick={() => { setEditTask(t); setForm({ name: t.name, action: t.action, schedule_type: t.schedule_type || 'once', schedule_time: t.schedule_time || '02:00', scheduled_at: '', schedule_weekday: t.schedule_weekday ?? 1, schedule_monthday: t.schedule_monthday ?? 1, reboot: t.reboot || false, health_check: t.health_check || false, health_check_timeout: t.health_check_timeout || 600, security_only: t.security_only || false, script: t.script || '', script_timeout: t.script_timeout || 300, webhook_url: t.webhook_url || '', active: t.active !== false }); setShowDialog(true) }} title="Bearbeiten" className="p-1 text-gray-500 hover:text-blue-400"><Settings className="w-3.5 h-3.5" /></button>
<button onClick={() => api.toggleTask(t.id).then(loadTasks)} title={t.active ? 'Deaktivieren' : 'Aktivieren'} className="p-1 text-gray-500 hover:text-yellow-400"><Monitor className="w-3.5 h-3.5" /></button> <button onClick={() => api.toggleTask(t.id).then(loadTasks)} title={t.active ? 'Deaktivieren' : 'Aktivieren'} className="p-1 text-gray-500 hover:text-yellow-400"><Monitor className="w-3.5 h-3.5" /></button>
<button onClick={() => confirm('Job löschen?') && api.deleteTask(t.id).then(loadTasks)} title="Löschen" className="p-1 text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button> <button onClick={() => confirm('Job löschen?') && api.deleteTask(t.id).then(loadTasks)} title="Löschen" className="p-1 text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
</div> </div>
@ -1015,6 +1056,7 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
const [error, setError] = useState('') const [error, setError] = useState('')
const [sshCopied, setSshCopied] = useState(null) const [sshCopied, setSshCopied] = useState(null)
const [terminalOpen, setTerminalOpen] = useState(false) const [terminalOpen, setTerminalOpen] = useState(false)
const [scriptOpen, setScriptOpen] = useState(false)
const backendHost = useSettingsStore.getState().getBackendHost() const backendHost = useSettingsStore.getState().getBackendHost()
@ -1085,6 +1127,9 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
{terminalOpen && ( {terminalOpen && (
<TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} /> <TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} />
)} )}
{scriptOpen && (
<ScriptModal agentId={agentId} agentName={agent?.name} onClose={() => setScriptOpen(false)} />
)}
{/* Schnellzugriff */} {/* Schnellzugriff */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
@ -1112,6 +1157,13 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
<Terminal className="w-4 h-4" /> <Terminal className="w-4 h-4" />
Web Terminal Web Terminal
</button> </button>
<button
onClick={() => setScriptOpen(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-700 hover:bg-purple-600 rounded text-sm text-white transition-colors"
>
<FileCode className="w-4 h-4" />
Script
</button>
<button <button
onClick={() => setCustomOpen(!customOpen)} onClick={() => setCustomOpen(!customOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300 transition-colors"

View File

@ -0,0 +1,119 @@
import { useState, useRef, useEffect } from 'react'
import { X, Play, Loader, Terminal, Copy, Check } from 'lucide-react'
import api from '../api/client'
export default function ScriptModal({ agentId, agentName, onClose }) {
const [script, setScript] = useState('#!/bin/bash\n\n')
const [timeout, setTimeout_] = useState(60)
const [running, setRunning] = useState(false)
const [output, setOutput] = useState(null)
const [error, setError] = useState(null)
const [copied, setCopied] = useState(false)
const outputRef = useRef(null)
useEffect(() => {
if (output !== null && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, [output])
const run = async () => {
if (!script.trim()) return
setRunning(true)
setOutput(null)
setError(null)
try {
const res = await api.execCommand(agentId, script, timeout)
setOutput(res?.data?.output ?? res?.output ?? '(kein Output)')
} catch (e) {
setError(e.message)
if (e.data?.output) setOutput(e.data.output)
} finally {
setRunning(false)
}
}
const copyOutput = () => {
if (output) {
navigator.clipboard.writeText(output)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}>
<div className="bg-gray-900 rounded-xl border border-gray-700 shadow-2xl flex flex-col"
style={{ width: '80vw', maxWidth: '1000px', height: '80vh' }}
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3 border-b border-gray-700/60 bg-gray-800/50 rounded-t-xl">
<Terminal className="w-4 h-4 text-orange-400" />
<span className="text-sm font-mono text-gray-200">Script ausführen <span className="text-orange-400">{agentName}</span></span>
<div className="ml-auto flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-gray-400">
Timeout (s):
<input type="number" value={timeout} onChange={e => setTimeout_(parseInt(e.target.value) || 60)}
className="w-16 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-orange-500" />
</label>
<button onClick={run} disabled={running || !script.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors">
{running ? <><Loader className="w-3.5 h-3.5 animate-spin" /> Läuft...</> : <><Play className="w-3.5 h-3.5" /> Ausführen</>}
</button>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Split: Editor + Output */}
<div className="flex flex-1 min-h-0 divide-x divide-gray-700/60">
{/* Script Editor */}
<div className="flex flex-col flex-1 min-w-0">
<div className="px-3 py-1.5 text-[10px] text-gray-500 uppercase tracking-wider bg-gray-800/30">Script</div>
<textarea
value={script}
onChange={e => setScript(e.target.value)}
spellCheck={false}
className="flex-1 w-full bg-transparent text-sm font-mono text-gray-200 p-3 resize-none focus:outline-none"
placeholder="#!/bin/bash&#10;&#10;# Script hier eingeben..."
/>
</div>
{/* Output */}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center px-3 py-1.5 bg-gray-800/30">
<span className="text-[10px] text-gray-500 uppercase tracking-wider flex-1">Output</span>
{output && (
<button onClick={copyOutput} className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300">
{copied ? <><Check className="w-3 h-3 text-green-400" /> Kopiert</> : <><Copy className="w-3 h-3" /> Kopieren</>}
</button>
)}
</div>
<div ref={outputRef}
className="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed">
{running && (
<div className="flex items-center gap-2 text-yellow-400">
<Loader className="w-3 h-3 animate-spin" /> Script läuft...
</div>
)}
{error && !running && (
<div className="text-red-400 mb-2">Fehler: {error}</div>
)}
{output !== null && (
<pre className={`whitespace-pre-wrap break-all ${error ? 'text-red-300' : 'text-green-300'}`}>
{output || '(kein Output)'}
</pre>
)}
{output === null && !running && (
<span className="text-gray-600">Noch kein Script ausgeführt</span>
)}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,14 +1,21 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { Shield } from 'lucide-react' import { Shield, KeyRound } from 'lucide-react'
export default function Login() { export default function Login() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
// 2FA-Schritt
const [totpRequired, setTotpRequired] = useState(false)
const [totpToken, setTotpToken] = useState('')
const [totpCode, setTotpCode] = useState('')
const totpRef = useRef(null)
const { login, validateTOTP } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@ -16,8 +23,14 @@ export default function Login() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
await login(username, password) const result = await login(username, password)
if (result?.totp_required) {
setTotpToken(result.totp_token)
setTotpRequired(true)
setTimeout(() => totpRef.current?.focus(), 100)
} else {
navigate('/') navigate('/')
}
} catch (err) { } catch (err) {
setError(err.message || 'Login fehlgeschlagen') setError(err.message || 'Login fehlgeschlagen')
} finally { } finally {
@ -25,6 +38,23 @@ export default function Login() {
} }
} }
const handleTOTP = async (e) => {
e.preventDefault()
if (totpCode.length !== 6) return
setError('')
setLoading(true)
try {
await validateTOTP(totpToken, totpCode)
navigate('/')
} catch (err) {
setError(err.message || 'Ungültiger Code')
setTotpCode('')
totpRef.current?.focus()
} finally {
setLoading(false)
}
}
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-950"> <div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
@ -34,13 +64,13 @@ export default function Login() {
<p className="text-gray-500 text-sm mt-1">Remote Monitoring & Management</p> <p className="text-gray-500 text-sm mt-1">Remote Monitoring & Management</p>
</div> </div>
{!totpRequired ? (
<form onSubmit={handleSubmit} className="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4"> <form onSubmit={handleSubmit} className="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
{error && ( {error && (
<div className="bg-red-900/30 border border-red-800 text-red-400 px-3 py-2 rounded text-sm"> <div className="bg-red-900/30 border border-red-800 text-red-400 px-3 py-2 rounded text-sm">
{error} {error}
</div> </div>
)} )}
<div> <div>
<label className="block text-sm text-gray-400 mb-1">Benutzer</label> <label className="block text-sm text-gray-400 mb-1">Benutzer</label>
<input <input
@ -51,7 +81,6 @@ export default function Login() {
autoFocus autoFocus
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-gray-400 mb-1">Passwort</label> <label className="block text-sm text-gray-400 mb-1">Passwort</label>
<input <input
@ -61,7 +90,6 @@ export default function Login() {
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:border-orange-500" className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:border-orange-500"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
@ -70,6 +98,51 @@ export default function Login() {
{loading ? 'Anmelden...' : 'Anmelden'} {loading ? 'Anmelden...' : 'Anmelden'}
</button> </button>
</form> </form>
) : (
<form onSubmit={handleTOTP} className="bg-gray-900 rounded-lg border border-gray-800 p-6 space-y-4">
<div className="text-center mb-2">
<KeyRound className="w-8 h-8 text-orange-500 mx-auto mb-2" />
<p className="text-sm text-gray-300 font-medium">2-Faktor-Authentifizierung</p>
<p className="text-xs text-gray-500 mt-1">Code aus der Authenticator-App eingeben</p>
</div>
{error && (
<div className="bg-red-900/30 border border-red-800 text-red-400 px-3 py-2 rounded text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm text-gray-400 mb-1">6-stelliger Code</label>
<input
ref={totpRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '')
setTotpCode(v)
}}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-center text-xl tracking-widest focus:outline-none focus:border-orange-500"
placeholder="000000"
/>
</div>
<button
type="submit"
disabled={loading || totpCode.length !== 6}
className="w-full bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 text-white font-medium py-2 rounded transition-colors"
>
{loading ? 'Prüfen...' : 'Bestätigen'}
</button>
<button
type="button"
onClick={() => { setTotpRequired(false); setError(''); setTotpCode('') }}
className="w-full text-gray-500 hover:text-gray-300 text-sm py-1"
>
Zurück
</button>
</form>
)}
</div> </div>
</div> </div>
) )

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
import api from '../api/client' import api from '../api/client'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key, Save, Settings } from 'lucide-react' import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key, Save, Settings, QrCode, ShieldCheck, ShieldOff } from 'lucide-react'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
export default function SettingsPage() { export default function SettingsPage() {
@ -191,18 +191,7 @@ export default function SettingsPage() {
<APIKeysSection /> <APIKeysSection />
{/* 2FA */} {/* 2FA */}
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden"> <TwoFactorSection />
<div className="px-4 py-3 border-b border-gray-800">
<span className="text-sm font-medium text-gray-300">Zwei-Faktor-Authentifizierung (2FA)</span>
</div>
<div className="px-4 py-6 flex items-center gap-3">
<ShieldAlert className="w-6 h-6 text-gray-600" />
<div>
<div className="text-sm text-gray-400">2FA ist noch nicht verfuegbar</div>
<div className="text-xs text-gray-600 mt-0.5">TOTP-basierte Zwei-Faktor-Authentifizierung wird in einer zukuenftigen Version implementiert.</div>
</div>
</div>
</div>
{/* System Settings */} {/* System Settings */}
<SystemSettingsSection /> <SystemSettingsSection />
@ -362,6 +351,158 @@ function APIKeysSection() {
) )
} }
function TwoFactorSection() {
const { user } = useAuthStore()
const [step, setStep] = useState('idle') // idle | setup | confirm | disable
const [setupData, setSetupData] = useState(null)
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
const totpEnabled = user?.totp_enabled
const handleSetup = async () => {
setError(''); setMsg('')
try {
const data = await api.setupTOTP()
setSetupData(data)
setStep('setup')
} catch (e) {
setError(e.message)
}
}
const handleConfirm = async () => {
setError('')
try {
await api.confirmTOTP(code)
setMsg('2FA aktiviert')
setStep('idle')
setCode('')
setSetupData(null)
// user neu laden
await useAuthStore.getState().checkAuth()
} catch (e) {
setError(e.message)
}
}
const handleDisable = async () => {
setError('')
try {
await api.disableTOTP(password, code)
setMsg('2FA deaktiviert')
setStep('idle')
setCode(''); setPassword('')
await useAuthStore.getState().checkAuth()
} catch (e) {
setError(e.message)
}
}
return (
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800 flex items-center justify-between">
<span className="text-sm font-medium text-gray-300">Zwei-Faktor-Authentifizierung (2FA)</span>
{totpEnabled
? <span className="flex items-center gap-1 text-xs text-green-400"><ShieldCheck className="w-3.5 h-3.5" />Aktiv</span>
: <span className="flex items-center gap-1 text-xs text-gray-500"><ShieldOff className="w-3.5 h-3.5" />Inaktiv</span>
}
</div>
<div className="px-4 py-4 space-y-4">
{error && <div className="bg-red-900/30 border border-red-800 text-red-400 px-3 py-2 rounded text-sm">{error}</div>}
{msg && <div className="bg-green-900/30 border border-green-800 text-green-400 px-3 py-2 rounded text-sm">{msg}</div>}
{step === 'idle' && (
<div className="flex items-center justify-between">
<div className="text-sm text-gray-400">
{totpEnabled
? 'TOTP-Authentifizierung ist aktiv. Jeder Login erfordert einen 6-stelligen Code.'
: 'Schütze deinen Account mit einer Authenticator-App (z.B. Google Authenticator, Aegis).'}
</div>
{totpEnabled
? <button onClick={() => { setStep('disable'); setMsg('') }}
className="ml-4 shrink-0 text-sm text-red-400 hover:text-red-300">Deaktivieren</button>
: <button onClick={handleSetup}
className="ml-4 shrink-0 flex items-center gap-1 text-sm text-orange-400 hover:text-orange-300">
<QrCode className="w-4 h-4" />Einrichten
</button>
}
</div>
)}
{step === 'setup' && setupData && (
<div className="space-y-4">
<div className="text-sm text-gray-300">
1. Scanne den QR-Code mit deiner Authenticator-App oder gib den Secret manuell ein.
</div>
<div className="bg-gray-800 rounded p-3">
<div className="text-xs text-gray-500 mb-1">Secret (manuell)</div>
<code className="text-orange-400 text-sm tracking-widest break-all">{setupData.secret}</code>
</div>
<div className="bg-gray-800 rounded p-3">
<div className="text-xs text-gray-500 mb-1">OTP URI (für QR-Code-Scanner)</div>
<code className="text-gray-400 text-xs break-all">{setupData.uri}</code>
</div>
<div className="text-sm text-gray-300">
2. Gib den ersten Code ein um die Einrichtung zu bestätigen:
</div>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="w-40 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-center text-lg tracking-widest focus:outline-none focus:border-orange-500"
/>
<div className="flex gap-2">
<button onClick={handleConfirm} disabled={code.length !== 6}
className="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white">
Aktivieren
</button>
<button onClick={() => { setStep('idle'); setCode(''); setSetupData(null); setError('') }}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300">
Abbrechen
</button>
</div>
</div>
)}
{step === 'disable' && (
<div className="space-y-3">
<div className="text-sm text-gray-300">Zur Deaktivierung: Passwort + aktuellen 2FA-Code eingeben.</div>
<div>
<label className="text-xs text-gray-500">Passwort</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
className="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500" />
</div>
<div>
<label className="text-xs text-gray-500">2FA-Code</label>
<input type="text" inputMode="numeric" maxLength={6} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="w-full mt-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm text-center tracking-widest focus:outline-none focus:border-orange-500" />
</div>
<div className="flex gap-2">
<button onClick={handleDisable} disabled={!password || code.length !== 6}
className="px-4 py-2 bg-red-700 hover:bg-red-600 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white">
2FA deaktivieren
</button>
<button onClick={() => { setStep('idle'); setCode(''); setPassword(''); setError('') }}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm text-gray-300">
Abbrechen
</button>
</div>
</div>
)}
</div>
</div>
)
}
function SystemSettingsSection() { function SystemSettingsSection() {
const { settings, fetchSettings } = useSettingsStore() const { settings, fetchSettings } = useSettingsStore()
const [form, setForm] = useState({}) const [form, setForm] = useState({})

View File

@ -9,8 +9,18 @@ export const useAuthStore = create((set) => ({
login: async (username, password) => { login: async (username, password) => {
const data = await api.login(username, password) const data = await api.login(username, password)
if (data.totp_required) {
// Noch nicht eingeloggt — TOTP-Schritt nötig
return data
}
set({ user: data.user, isAuthenticated: true })
useSettingsStore.getState().fetchSettings()
return data
},
validateTOTP: async (totpToken, code) => {
const data = await api.validateTOTP(totpToken, code)
set({ user: data.user, isAuthenticated: true }) set({ user: data.user, isAuthenticated: true })
// Load system settings after login
useSettingsStore.getState().fetchSettings() useSettingsStore.getState().fetchSettings()
return data return data
}, },