- last_api_key Spalte in agents, wird bei Heartbeat gesetzt - GetAgentsByCustomer liefert last_api_key mit - Frontend: rot + Warnsymbol wenn Key abweicht vom Kunden-Agent-Key - Grau wenn noch kein Heartbeat mit Key-Info
302 lines
13 KiB
JavaScript
302 lines
13 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import api from '../api/client'
|
|
import { copyToClipboard } from '../utils/clipboard'
|
|
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle } from 'lucide-react'
|
|
|
|
const PERM_LABELS = {
|
|
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
|
|
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50' },
|
|
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50' },
|
|
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
|
|
}
|
|
|
|
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 [expandedId, setExpandedId] = useState(null)
|
|
|
|
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()
|
|
}
|
|
|
|
const toggleExpand = (id) => {
|
|
setExpandedId(prev => prev === id ? null : id)
|
|
}
|
|
|
|
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 w-6"></th>
|
|
<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></td>
|
|
<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"
|
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
/>
|
|
</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) => (
|
|
<>
|
|
{editId === c.id ? (
|
|
<tr key={`edit-${c.id}`} className="bg-gray-800/30">
|
|
<td></td>
|
|
<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>
|
|
</tr>
|
|
) : (
|
|
<tr
|
|
key={`row-${c.id}`}
|
|
className={`hover:bg-gray-800/50 cursor-pointer transition-colors ${expandedId === c.id ? 'bg-gray-800/30' : ''}`}
|
|
onClick={() => toggleExpand(c.id)}
|
|
>
|
|
<td className="px-3 py-2.5 text-gray-600">
|
|
{expandedId === c.id
|
|
? <ChevronDown className="w-3.5 h-3.5" />
|
|
: <ChevronRight className="w-3.5 h-3.5" />
|
|
}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
|
|
<td className="px-4 py-2.5 text-white">{c.name}</td>
|
|
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
|
|
{new Date(c.created_at).toLocaleDateString('de-DE')}
|
|
</td>
|
|
<td className="px-4 py-2.5" onClick={(e) => e.stopPropagation()}>
|
|
<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 p-1 rounded hover:bg-gray-700"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(c.id)}
|
|
className="text-gray-500 hover:text-red-400 p-1 rounded hover:bg-gray-700"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{expandedId === c.id && (
|
|
<tr key={`expand-${c.id}`}>
|
|
<td colSpan={5} className="px-0 py-0">
|
|
<CustomerKeyPanel customerId={c.id} />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{!loading && customers.length === 0 && !showAdd && (
|
|
<div className="text-center py-8 text-gray-500">Keine Kunden angelegt</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomerKeyPanel({ customerId }) {
|
|
const [keys, setKeys] = useState(null)
|
|
const [agents, setAgents] = useState(null)
|
|
const [copied, setCopied] = useState(null)
|
|
|
|
useEffect(() => {
|
|
api.getCustomerAPIKeys(customerId).then(setKeys)
|
|
api.getCustomerAgents(customerId).then(setAgents)
|
|
}, [customerId])
|
|
|
|
const copyKey = (key, id) => {
|
|
copyToClipboard(key)
|
|
setCopied(id)
|
|
setTimeout(() => setCopied(null), 2000)
|
|
}
|
|
|
|
const loading = keys === null || agents === null
|
|
if (loading) {
|
|
return <div className="px-8 py-3 text-gray-500 text-xs bg-gray-800/20 border-t border-gray-800">Laden...</div>
|
|
}
|
|
|
|
// Agent-Key fuer diesen Kunden ermitteln (zum Abgleich welcher Agent ihn nutzt)
|
|
const agentKey = keys.find(k => k.permissions === 'agent')
|
|
|
|
return (
|
|
<div className="bg-gray-800/20 border-t border-gray-800 px-8 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-3">
|
|
|
|
{/* Agents */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Monitor className="w-3.5 h-3.5 text-gray-500" />
|
|
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
|
|
</div>
|
|
{agents.length === 0 ? (
|
|
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
|
|
) : (
|
|
agents.map((a) => {
|
|
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
|
|
const unknownKey = agentKey && !a.last_api_key
|
|
return (
|
|
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
a.status === 'online' ? 'bg-green-400' :
|
|
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
|
|
}`} />
|
|
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>
|
|
{a.name}
|
|
</span>
|
|
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
|
|
{wrongKey && (
|
|
<span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}>
|
|
<AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" />
|
|
</span>
|
|
)}
|
|
{unknownKey && (
|
|
<span title="Noch kein Heartbeat mit Key-Info">
|
|
<AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* API-Keys */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Key className="w-3.5 h-3.5 text-gray-500" />
|
|
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
|
|
</div>
|
|
{keys.length === 0 ? (
|
|
<div className="text-xs text-gray-600">Keine Keys</div>
|
|
) : (
|
|
keys.map((k) => {
|
|
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
|
return (
|
|
<div key={k.id} className="flex items-center gap-2.5">
|
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
|
|
{perm.label}
|
|
</span>
|
|
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
|
|
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
|
|
</code>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); copyKey(k.key, k.id) }}
|
|
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
|
|
title="Key kopieren"
|
|
>
|
|
{copied === k.id
|
|
? <Check className="w-3.5 h-3.5 text-green-400" />
|
|
: <Copy className="w-3.5 h-3.5" />
|
|
}
|
|
</button>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
)
|
|
}
|