Frontend: React+Vite+Tailwind, Login, Dashboard, Agents, Customers, Settings, Dark Theme, Nginx auf .20

This commit is contained in:
cynfo3000
2026-02-28 15:40:07 +01:00
parent c6c8257cf0
commit c56fded7d7
24 changed files with 5548 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '../api/client'
import StatusBadge from '../components/StatusBadge'
import {
Cpu,
HardDrive,
MemoryStick,
Network,
Clock,
Shield,
ArrowLeft,
Terminal,
Download,
} from 'lucide-react'
export default function AgentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const reload = () => {
api.getAgent(id).then(setData).finally(() => setLoading(false))
}
useEffect(() => {
reload()
const interval = setInterval(reload, 30000)
return () => clearInterval(interval)
}, [id])
if (loading) return <div className="text-gray-500">Laden...</div>
if (!data) return <div className="text-red-400">Agent nicht gefunden</div>
const { agent, system_data: sys, status } = data
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-white">{agent.name}</h1>
<StatusBadge status={status} />
</div>
<div className="text-sm text-gray-500 mt-0.5">
{agent.ip} — {agent.opnsense_version || 'Version unbekannt'}
</div>
</div>
<div className="flex gap-2">
<ActionButton icon={Download} label="Backup" onClick={() => api.triggerBackup(id)} />
<ActionButton icon={Terminal} label="Shell" onClick={() => {}} />
</div>
</div>
{!sys ? (
<div className="text-gray-500">Noch keine Systemdaten vorhanden</div>
) : (
<>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<QuickStat
icon={Cpu}
label="CPU"
value={sys.cpu ? `${sys.cpu.usage_percent?.toFixed(1)}%` : '—'}
sub={sys.cpu?.model || ''}
/>
<QuickStat
icon={MemoryStick}
label="RAM"
value={sys.memory ? formatBytes(sys.memory.used_bytes) : '—'}
sub={sys.memory ? `von ${formatBytes(sys.memory.total_bytes)}` : ''}
/>
<QuickStat
icon={HardDrive}
label="Disks"
value={sys.disks ? `${sys.disks.length} Datasets` : '—'}
sub=""
/>
<QuickStat
icon={Clock}
label="Uptime"
value={sys.uptime_seconds ? formatUptime(sys.uptime_seconds) : '—'}
sub=""
/>
</div>
{/* Network Interfaces */}
{sys.network_interfaces && sys.network_interfaces.length > 0 && (
<Section title="Netzwerk-Interfaces" icon={Network}>
<div className="divide-y divide-gray-800">
{sys.network_interfaces
.filter((i) => i.addresses && i.addresses.length > 0)
.map((iface) => (
<div key={iface.name} className="px-4 py-2 flex items-center justify-between">
<div>
<span className="text-sm text-white font-mono">{iface.name}</span>
<span className={`ml-2 text-xs ${iface.status === 'active' ? 'text-green-400' : 'text-gray-500'}`}>
{iface.status}
</span>
<div className="text-xs text-gray-500">
{iface.addresses?.filter((a) => !a.includes(':')).join(', ')}
</div>
</div>
<div className="text-right text-xs text-gray-500">
<div>RX: {formatBytes(iface.rx_bytes)}</div>
<div>TX: {formatBytes(iface.tx_bytes)}</div>
</div>
</div>
))}
</div>
</Section>
)}
{/* Services */}
{sys.services && sys.services.length > 0 && (
<Section title="Dienste" icon={Shield}>
<div className="px-4 py-2 flex flex-wrap gap-2">
{sys.services.map((svc) => (
<span
key={svc.name}
className={`text-xs px-2 py-1 rounded ${
svc.running
? 'bg-green-900/30 text-green-400 border border-green-800/50'
: 'bg-gray-800 text-gray-500 border border-gray-700'
}`}
>
{svc.name}
</span>
))}
</div>
</Section>
)}
{/* Gateways */}
{sys.gateways && sys.gateways.length > 0 && (
<Section title="Gateways">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">Gateway</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">RTT</th>
<th className="px-4 py-2 font-medium">Loss</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{sys.gateways.map((gw) => (
<tr key={gw.name}>
<td className="px-4 py-2 text-white">{gw.name}</td>
<td className="px-4 py-2 text-gray-400">{gw.gateway}</td>
<td className="px-4 py-2">
<span className={gw.status === 'none' ? 'text-green-400' : 'text-yellow-400'}>
{gw.status === 'none' ? 'OK' : gw.status}
</span>
</td>
<td className="px-4 py-2 text-gray-400">{gw.delay || '—'}</td>
<td className="px-4 py-2 text-gray-400">{gw.loss || '—'}</td>
</tr>
))}
</tbody>
</table>
</Section>
)}
{/* Disks */}
{sys.disks && sys.disks.length > 0 && (
<Section title="ZFS Datasets" icon={HardDrive}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b border-gray-800">
<th className="px-4 py-2 font-medium">Dataset</th>
<th className="px-4 py-2 font-medium">Mountpoint</th>
<th className="px-4 py-2 font-medium">Belegt</th>
<th className="px-4 py-2 font-medium">Gesamt</th>
<th className="px-4 py-2 font-medium">%</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{sys.disks.map((d) => {
const pct = d.total_bytes > 0 ? (d.used_bytes / d.total_bytes * 100) : 0
return (
<tr key={d.filesystem}>
<td className="px-4 py-2 text-white font-mono text-xs">{d.filesystem}</td>
<td className="px-4 py-2 text-gray-400 text-xs">{d.mount_point}</td>
<td className="px-4 py-2 text-gray-400">{formatBytes(d.used_bytes)}</td>
<td className="px-4 py-2 text-gray-400">{formatBytes(d.total_bytes)}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-800 rounded overflow-hidden">
<div
className={`h-full rounded ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-green-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-500">{pct.toFixed(0)}%</span>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</Section>
)}
</>
)}
</div>
)
}
function Section({ title, icon: Icon, children }) {
return (
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<div className="px-4 py-2.5 border-b border-gray-800 flex items-center gap-2">
{Icon && <Icon className="w-4 h-4 text-gray-500" />}
<span className="text-sm font-medium text-gray-300">{title}</span>
</div>
{children}
</div>
)
}
function ActionButton({ icon: Icon, label, onClick }) {
return (
<button
onClick={onClick}
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded text-sm text-gray-300 transition-colors"
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
)
}
function QuickStat({ icon: Icon, label, value, sub }) {
return (
<div className="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
<div className="flex items-center gap-2 text-gray-500 mb-1">
<Icon className="w-4 h-4" />
<span className="text-xs">{label}</span>
</div>
<div className="text-lg font-bold text-white">{value}</div>
{sub && <div className="text-xs text-gray-500 truncate">{sub}</div>}
</div>
)
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
if (d > 0) return `${d}d ${h}h`
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import StatusBadge from '../components/StatusBadge'
import { Search } from 'lucide-react'
export default function Agents() {
const [agents, setAgents] = useState([])
const [customers, setCustomers] = useState([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([api.getAgents(), api.getCustomers()])
.then(([a, c]) => {
setAgents(a || [])
setCustomers(c || [])
})
.finally(() => setLoading(false))
}, [])
const customerMap = {}
customers.forEach((c) => (customerMap[c.id] = c))
const filtered = agents.filter((a) => {
const q = search.toLowerCase()
const cust = a.customer_id ? customerMap[a.customer_id] : null
return (
a.name.toLowerCase().includes(q) ||
a.ip.toLowerCase().includes(q) ||
a.hostname.toLowerCase().includes(q) ||
(cust && (cust.name.toLowerCase().includes(q) || cust.number.toLowerCase().includes(q)))
)
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Firewalls</h1>
<span className="text-sm text-gray-500">{agents.length} gesamt</span>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Suchen (Name, IP, Kunde)..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
/>
</div>
{loading ? (
<div className="text-gray-500">Laden...</div>
) : (
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-left text-gray-500">
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">IP</th>
<th className="px-4 py-2 font-medium hidden lg:table-cell">Version</th>
<th className="px-4 py-2 font-medium hidden lg:table-cell">Kunde</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">Letzter Heartbeat</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{filtered.map((agent) => {
const cust = agent.customer_id ? customerMap[agent.customer_id] : null
return (
<tr
key={agent.id}
className="hover:bg-gray-800/50 transition-colors cursor-pointer"
>
<td className="px-4 py-2.5">
<StatusBadge status={agent.status} />
</td>
<td className="px-4 py-2.5">
<Link to={`/agents/${agent.id}`} className="text-white hover:text-orange-400">
{agent.name}
</Link>
</td>
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{agent.ip}</td>
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">
{agent.opnsense_version || '—'}
</td>
<td className="px-4 py-2.5 hidden lg:table-cell">
{cust ? (
<span className="text-gray-400">
<span className="text-orange-400">{cust.number}</span> {cust.name}
</span>
) : (
<span className="text-gray-600">—</span>
)}
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
{agent.last_heartbeat
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
: '—'}
</td>
</tr>
)
})}
</tbody>
</table>
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">
{search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
</div>
)}
</div>
)}
</div>
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { Plus, Trash2, Edit2, X, Check } from 'lucide-react'
export default function Customers() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [showAdd, setShowAdd] = useState(false)
const [editId, setEditId] = useState(null)
const [form, setForm] = useState({ number: '', name: '' })
const reload = () => {
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
const handleAdd = async () => {
if (!form.number || !form.name) return
await api.createCustomer(form.number, form.name)
setForm({ number: '', name: '' })
setShowAdd(false)
reload()
}
const handleUpdate = async (id) => {
await api.updateCustomer(id, form.number, form.name)
setEditId(null)
reload()
}
const handleDelete = async (id) => {
if (!confirm('Kunde wirklich loeschen?')) return
await api.deleteCustomer(id)
reload()
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Kunden</h1>
<button
onClick={() => { setShowAdd(true); setForm({ number: '', name: '' }) }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white transition-colors"
>
<Plus className="w-4 h-4" />
Neuer Kunde
</button>
</div>
<div className="bg-gray-900 rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-left text-gray-500">
<th className="px-4 py-2 font-medium">Nummer</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
<th className="px-4 py-2 font-medium w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{showAdd && (
<tr>
<td className="px-4 py-2">
<input
type="text"
placeholder="K05116"
value={form.number}
onChange={(e) => setForm({ ...form, number: 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"
autoFocus
/>
</td>
<td className="px-4 py-2">
<input
type="text"
placeholder="Kundenname"
value={form.name}
onChange={(e) => setForm({ ...form, 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"
/>
</td>
<td className="hidden md:table-cell"></td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={handleAdd} className="text-green-400 hover:text-green-300">
<Check className="w-4 h-4" />
</button>
<button onClick={() => setShowAdd(false)} className="text-gray-500 hover:text-gray-300">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)}
{customers.map((c) => (
<tr key={c.id} className="hover:bg-gray-800/50">
{editId === c.id ? (
<>
<td className="px-4 py-2">
<input
type="text"
value={form.number}
onChange={(e) => setForm({ ...form, number: 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"
/>
</td>
<td className="px-4 py-2">
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, 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"
/>
</td>
<td className="hidden md:table-cell"></td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={() => handleUpdate(c.id)} className="text-green-400 hover:text-green-300">
<Check className="w-4 h-4" />
</button>
<button onClick={() => setEditId(null)} className="text-gray-500 hover:text-gray-300">
<X className="w-4 h-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2 text-orange-400 font-mono">{c.number}</td>
<td className="px-4 py-2 text-white">{c.name}</td>
<td className="px-4 py-2 text-gray-500 text-xs hidden md:table-cell">
{new Date(c.created_at).toLocaleDateString('de-DE')}
</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button
onClick={() => { setEditId(c.id); setForm({ number: c.number, name: c.name }) }}
className="text-gray-500 hover:text-gray-300"
>
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(c.id)} className="text-gray-500 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
{!loading && customers.length === 0 && !showAdd && (
<div className="text-center py-8 text-gray-500">Keine Kunden angelegt</div>
)}
</div>
</div>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../api/client'
import StatusBadge from '../components/StatusBadge'
import { Server, Users, Activity, AlertTriangle } from 'lucide-react'
export default function Dashboard() {
const [agents, setAgents] = useState([])
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([api.getAgents(), api.getCustomers()])
.then(([a, c]) => {
setAgents(a || [])
setCustomers(c || [])
})
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div className="text-gray-500">Laden...</div>
}
const online = agents.filter((a) => a.status === 'online').length
const offline = agents.filter((a) => a.status === 'offline').length
const stale = agents.filter((a) => a.status === 'stale').length
const unassigned = agents.filter((a) => !a.customer_id).length
// Agents nach Kunde gruppieren
const customerMap = {}
customers.forEach((c) => {
customerMap[c.id] = { ...c, agents: [] }
})
agents.forEach((a) => {
if (a.customer_id && customerMap[a.customer_id]) {
customerMap[a.customer_id].agents.push(a)
}
})
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white">Dashboard</h1>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={Server} label="Firewalls" value={agents.length} color="text-blue-400" />
<StatCard icon={Activity} label="Online" value={online} color="text-green-400" />
<StatCard icon={AlertTriangle} label="Offline" value={offline} color="text-red-400" />
<StatCard icon={Users} label="Kunden" value={customers.length} color="text-orange-400" />
</div>
{/* Agent-Liste nach Kunde */}
<div className="space-y-4">
{Object.values(customerMap)
.filter((c) => c.agents.length > 0)
.sort((a, b) => a.number.localeCompare(b.number))
.map((customer) => (
<div key={customer.id} className="bg-gray-900 rounded-lg border border-gray-800">
<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">
<span className="text-orange-400">{customer.number}</span> — {customer.name}
</span>
<span className="text-xs text-gray-500">
{customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
</span>
</div>
<div className="divide-y divide-gray-800">
{customer.agents.map((agent) => (
<AgentRow key={agent.id} agent={agent} />
))}
</div>
</div>
))}
{/* Nicht zugewiesene Agents */}
{unassigned > 0 && (
<div className="bg-gray-900 rounded-lg border border-gray-800">
<div className="px-4 py-3 border-b border-gray-800">
<span className="text-sm font-medium text-gray-500">Nicht zugewiesen</span>
</div>
<div className="divide-y divide-gray-800">
{agents
.filter((a) => !a.customer_id)
.map((agent) => (
<AgentRow key={agent.id} agent={agent} />
))}
</div>
</div>
)}
</div>
</div>
)
}
function StatCard({ icon: Icon, label, value, color }) {
return (
<div className="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
<div className="flex items-center gap-3">
<Icon className={`w-5 h-5 ${color}`} />
<div>
<div className="text-2xl font-bold text-white">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
</div>
</div>
)
}
function AgentRow({ agent }) {
const lastHB = agent.last_heartbeat
? new Date(agent.last_heartbeat).toLocaleString('de-DE')
: '—'
return (
<Link
to={`/agents/${agent.id}`}
className="flex items-center justify-between px-4 py-2.5 hover:bg-gray-800/50 transition-colors"
>
<div className="flex items-center gap-3">
<StatusBadge status={agent.status} size="dot" />
<div>
<div className="text-sm text-white">{agent.name}</div>
<div className="text-xs text-gray-500">{agent.ip}</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500">{agent.opnsense_version || '—'}</div>
<div className="text-xs text-gray-600">{lastHB}</div>
</div>
</Link>
)
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
import { Shield } from 'lucide-react'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/')
} catch (err) {
setError(err.message || 'Login fehlgeschlagen')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<Shield className="w-12 h-12 text-orange-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-white">RMM</h1>
<p className="text-gray-500 text-sm mt-1">Remote Monitoring & Management</p>
</div>
<form onSubmit={handleSubmit} className="bg-gray-900 rounded-lg border border-gray-800 p-6 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>
)}
<div>
<label className="block text-sm text-gray-400 mb-1">Benutzer</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:border-orange-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
<button
type="submit"
disabled={loading}
className="w-full bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 text-white font-medium py-2 rounded transition-colors"
>
{loading ? 'Anmelden...' : 'Anmelden'}
</button>
</form>
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { Plus, Trash2 } from 'lucide-react'
export default function SettingsPage() {
const [users, setUsers] = useState([])
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState({ username: '', password: '', display_name: '' })
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()
}
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">
<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} 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>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-600">
{u.last_login ? `Letzter Login: ${new Date(u.last_login).toLocaleString('de-DE')}` : 'Nie eingeloggt'}
</span>
<button onClick={() => handleDelete(u.id)} className="text-gray-500 hover:text-red-400">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)
}