rmm2/frontend/src/pages/SettingsPage.jsx
cynfo3000 10684dfc0a 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
2026-03-08 19:17:43 +01:00

613 lines
25 KiB
JavaScript

import { useEffect, useState } from 'react'
import api from '../api/client'
import { useAuthStore } from '../stores/auth'
import { copyToClipboard } from '../utils/clipboard'
import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key, Save, Settings, QrCode, ShieldCheck, ShieldOff } from 'lucide-react'
import { useSettingsStore } from '../stores/settings'
export default function SettingsPage() {
const [users, setUsers] = useState([])
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ username: '', password: '', display_name: '' })
const [pwChange, setPwChange] = useState(null)
const [pwForm, setPwForm] = useState({ password: '', confirm: '' })
const [pwMsg, setPwMsg] = useState('')
const { user: currentUser } = useAuthStore()
const reload = () => api.getUsers().then((u) => setUsers(u || []))
useEffect(() => { reload() }, [])
const handleAdd = async () => {
if (!form.username || !form.password) return
await api.createUser(form)
setForm({ username: '', password: '', display_name: '' })
setShowAdd(false)
reload()
}
const handleDelete = async (id) => {
if (!confirm('Benutzer wirklich loeschen?')) return
await api.deleteUser(id)
reload()
}
const handlePasswordChange = async () => {
setPwMsg('')
if (!pwForm.password || pwForm.password.length < 6) {
setPwMsg('Mindestens 6 Zeichen')
return
}
if (pwForm.password !== pwForm.confirm) {
setPwMsg('Passwoerter stimmen nicht ueberein')
return
}
try {
await api.changePassword(pwChange, pwForm.password)
setPwMsg('Passwort geaendert')
setTimeout(() => { setPwChange(null); setPwMsg(''); setPwForm({ password: '', confirm: '' }) }, 1500)
} catch (e) {
setPwMsg('Fehler: ' + e.message)
}
}
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white">Einstellungen</h1>
{/* Users */}
<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">Benutzer</span>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1 text-sm text-orange-400 hover:text-orange-300"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
</div>
{showAdd && (
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end flex-wrap">
<div>
<label className="text-xs text-gray-500">Benutzer</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-gray-500">Passwort</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</div>
<div>
<label className="text-xs text-gray-500">Anzeigename</label>
<input
type="text"
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</div>
<button onClick={handleAdd} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
Anlegen
</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
Abbrechen
</button>
</div>
)}
<div className="divide-y divide-gray-800">
{users.map((u) => (
<div key={u.id}>
<div className="px-4 py-2.5 flex items-center justify-between">
<div>
<span className="text-sm text-white">{u.username}</span>
{u.display_name && (
<span className="text-sm text-gray-500 ml-2">({u.display_name})</span>
)}
<span className="ml-2 text-xs text-gray-600">{u.role}</span>
{currentUser?.username === u.username && (
<span className="ml-2 text-xs text-orange-400">(Du)</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">
{u.last_login ? `Login: ${new Date(u.last_login).toLocaleString('de-DE')}` : 'Nie eingeloggt'}
</span>
<button
onClick={() => { setPwChange(pwChange === u.id ? null : u.id); setPwMsg(''); setPwForm({ password: '', confirm: '' }) }}
className="text-gray-500 hover:text-orange-400 p-1 rounded hover:bg-gray-800 transition-colors"
title="Passwort aendern"
>
<KeyRound className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(u.id)}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-800 transition-colors"
title="Benutzer loeschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Password Change */}
{pwChange === u.id && (
<div className="px-4 py-3 bg-gray-800/50 border-t border-gray-800 flex items-end gap-2 flex-wrap">
<div>
<label className="text-xs text-gray-500">Neues Passwort</label>
<input
type="password"
value={pwForm.password}
onChange={(e) => setPwForm({ ...pwForm, password: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
autoFocus
/>
</div>
<div>
<label className="text-xs text-gray-500">Bestaetigen</label>
<input
type="password"
value={pwForm.confirm}
onChange={(e) => setPwForm({ ...pwForm, confirm: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && handlePasswordChange()}
className="w-full bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
/>
</div>
<button
onClick={handlePasswordChange}
className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white"
>
Aendern
</button>
<button
onClick={() => { setPwChange(null); setPwMsg('') }}
className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400"
>
Abbrechen
</button>
{pwMsg && (
<span className={`text-sm ${pwMsg.startsWith('Fehler') || pwMsg.startsWith('Mindestens') || pwMsg.startsWith('Passwort') && !pwMsg.includes('geaendert') ? 'text-red-400' : pwMsg.includes('geaendert') ? 'text-green-400' : 'text-red-400'}`}>
{pwMsg}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
{/* API Keys */}
<APIKeysSection />
{/* 2FA */}
<TwoFactorSection />
{/* System Settings */}
<SystemSettingsSection />
</div>
)
}
function APIKeysSection() {
const [keys, setKeys] = useState([])
const [showAdd, setShowAdd] = useState(false)
const [newName, setNewName] = useState('')
const [newKey, setNewKey] = useState(null)
const [copied, setCopied] = useState(null)
const [loading, setLoading] = useState(true)
const reload = () => {
setLoading(true)
api.getAPIKeys()
.then(k => setKeys(k || []))
.finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
const handleCreate = async () => {
if (!newName.trim()) return
try {
const result = await api.createAPIKey(newName.trim())
setNewKey(result)
setNewName('')
reload()
} catch (e) {
alert('Fehler: ' + e.message)
}
}
const handleDelete = async (id, name) => {
if (!confirm(`API-Key "${name}" wirklich loeschen? Alle Agents mit diesem Key verlieren den Zugang.`)) return
await api.deleteAPIKey(id)
reload()
}
const copyText = (text, id) => {
copyToClipboard(text)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
}
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">
<div className="flex items-center gap-2">
<Key className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-300">API-Keys</span>
<span className="text-xs text-gray-600">({keys.length})</span>
</div>
<button
onClick={() => { setShowAdd(true); setNewKey(null); setNewName('') }}
className="flex items-center gap-1 text-sm text-orange-400 hover:text-orange-300"
>
<Plus className="w-4 h-4" />
Neuer Key
</button>
</div>
{/* Neuer Key Dialog */}
{showAdd && !newKey && (
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
<div className="flex-1">
<label className="text-xs text-gray-500">Bezeichnung</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="z.B. Agent-Key Kunde XY"
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
Erstellen
</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
Abbrechen
</button>
</div>
)}
{/* Neuer Key Anzeige */}
{newKey && (
<div className="px-4 py-3 border-b border-gray-800 bg-green-900/20">
<div className="text-sm text-green-400 mb-2">API-Key erstellt jetzt kopieren, er wird nur einmal angezeigt!</div>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-950 rounded px-3 py-2 text-sm text-white font-mono select-all">
{newKey.key}
</code>
<button
onClick={() => copyText(newKey.key, 'new')}
className="px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded text-gray-300 transition-colors"
>
{copied === 'new' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => { setNewKey(null); setShowAdd(false) }}
className="mt-2 text-xs text-gray-500 hover:text-gray-400"
>
Fertig
</button>
</div>
)}
{/* Key Liste */}
{loading ? (
<div className="px-4 py-3 text-gray-500 text-sm">Laden...</div>
) : keys.length === 0 ? (
<div className="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div>
) : (
<div className="divide-y divide-gray-800">
{keys.map((k) => (
<div key={k.id} className="px-4 py-2.5 flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-white">{k.name}</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-600 font-mono">{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}</span>
<button
onClick={() => copyText(k.key, k.id)}
className="text-gray-600 hover:text-orange-400 transition-colors"
title="Key kopieren"
>
{copied === k.id ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
</button>
<span className="text-xs text-gray-600">
Erstellt: {new Date(k.created_at).toLocaleDateString('de-DE')}
</span>
{k.last_used && (
<span className="text-xs text-gray-600">
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')}
</span>
)}
</div>
</div>
<button
onClick={() => handleDelete(k.id, k.name)}
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-800 transition-colors ml-2"
title="Key loeschen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}
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() {
const { settings, fetchSettings } = useSettingsStore()
const [form, setForm] = useState({})
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState('')
const [copied, setCopied] = useState(null)
useEffect(() => {
fetchSettings()
}, [])
useEffect(() => {
setForm({
backend_url_internal: settings.backend_url_internal || '',
backend_url_external: settings.backend_url_external || '',
api_key: settings.api_key || '',
})
}, [settings])
const handleSave = async () => {
setSaving(true)
setMsg('')
try {
await api.updateSettings(form)
await fetchSettings()
setMsg('Gespeichert')
setTimeout(() => setMsg(''), 3000)
} catch (e) {
setMsg('Fehler: ' + e.message)
} finally {
setSaving(false)
}
}
const copyText = (text, id) => {
copyToClipboard(text)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
}
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 gap-2">
<Settings className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-300">System-Einstellungen</span>
</div>
<div className="px-4 py-4 space-y-3">
<div>
<label className="text-xs text-gray-500">Backend URL (intern)</label>
<input
type="text"
value={form.backend_url_internal || ''}
onChange={(e) => setForm({ ...form, backend_url_internal: e.target.value })}
placeholder="https://192.168.85.13:8443"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
/>
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Tunnel-Zugriff und lokale Verbindungen</div>
</div>
<div>
<label className="text-xs text-gray-500">Backend URL (extern)</label>
<input
type="text"
value={form.backend_url_external || ''}
onChange={(e) => setForm({ ...form, backend_url_external: e.target.value })}
placeholder="https://example.dyndns.org:8443"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1"
/>
<div className="text-[10px] text-gray-600 mt-0.5">Fuer Remote-Firewalls in der Installationsanleitung</div>
</div>
<div>
<label className="text-xs text-gray-500">API-Key</label>
<div className="flex gap-2 mt-1">
<input
type="text"
value={form.api_key || ''}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-orange-500"
/>
<button
onClick={() => copyText(form.api_key, 'sys-apikey')}
className="px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded text-gray-400 transition-colors"
title="Kopieren"
>
{copied === 'sys-apikey' ? <Check className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="text-[10px] text-gray-600 mt-0.5">Wird in Installationsanleitungen angezeigt</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 disabled:text-gray-500 rounded text-sm text-white transition-colors"
>
<Save className="w-4 h-4" />
{saving ? 'Speichere...' : 'Speichern'}
</button>
{msg && (
<span className={`text-sm ${msg.startsWith('Fehler') ? 'text-red-400' : 'text-green-400'}`}>
{msg}
</span>
)}
</div>
</div>
</div>
)
}