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
613 lines
25 KiB
JavaScript
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>
|
|
)
|
|
}
|