feat: Kunden-Seite zeigt API-Keys beim Aufklappen
- Klick auf Kundenzeile klappt Key-Panel auf
- Zeigt Agent + Read-only Keys mit Permission-Badge
- Key teilweise maskiert, Kopier-Button vorhanden
- Backend: GET /api/v1/customers/{id}/apikeys
- DB: ListCustomerAPIKeys() nach customer_id gefiltert
This commit is contained in:
parent
fba824da8b
commit
5cc3476479
@ -58,6 +58,21 @@ func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, apiKey)
|
||||
}
|
||||
|
||||
// GET /api/v1/customers/{id}/apikeys
|
||||
func (h *Handler) listCustomerAPIKeys(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungueltige ID")
|
||||
return
|
||||
}
|
||||
keys, err := h.db.ListCustomerAPIKeys(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Laden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, keys)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/apikeys/{id}
|
||||
func (h *Handler) deleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
|
||||
@ -59,6 +59,7 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
||||
mux.HandleFunc("GET /api/v1/apikeys", h.listAPIKeys)
|
||||
mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey)
|
||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
|
||||
|
||||
// System Settings
|
||||
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
||||
|
||||
@ -1259,6 +1259,38 @@ func (d *Database) UpdateAPIKeyLastUsed(key string) {
|
||||
d.db.Exec("UPDATE api_keys SET last_used = NOW() WHERE key = $1", key)
|
||||
}
|
||||
|
||||
func (d *Database) ListCustomerAPIKeys(customerID int) ([]APIKey, error) {
|
||||
rows, err := d.db.Query(
|
||||
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE customer_id = $1 ORDER BY permissions, created_at",
|
||||
customerID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var keys []APIKey
|
||||
for rows.Next() {
|
||||
var k APIKey
|
||||
var lastUsed sql.NullTime
|
||||
var custID sql.NullInt64
|
||||
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastUsed.Valid {
|
||||
k.LastUsed = &lastUsed.Time
|
||||
}
|
||||
if custID.Valid {
|
||||
id := int(custID.Int64)
|
||||
k.CustomerID = &id
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
if keys == nil {
|
||||
keys = []APIKey{}
|
||||
}
|
||||
return keys, rows.Err()
|
||||
}
|
||||
|
||||
// MigrateConfigAPIKeys migriert API-Keys aus der Config in die DB (einmalig)
|
||||
// --- Audit Log ---
|
||||
|
||||
|
||||
@ -250,6 +250,10 @@ class ApiClient {
|
||||
return this.get('/api/v1/apikeys')
|
||||
}
|
||||
|
||||
getCustomerAPIKeys(customerId) {
|
||||
return this.get(`/api/v1/customers/${customerId}/apikeys`)
|
||||
}
|
||||
|
||||
createAPIKey(name, permissions = 'read', customerId = null) {
|
||||
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
||||
}
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { Plus, Trash2, Edit2, X, Check } from 'lucide-react'
|
||||
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key } 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([])
|
||||
@ -8,6 +15,7 @@ export default function Customers() {
|
||||
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))
|
||||
@ -35,6 +43,10 @@ export default function Customers() {
|
||||
reload()
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpandedId(prev => prev === id ? null : id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -52,6 +64,7 @@ export default function Customers() {
|
||||
<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>
|
||||
@ -61,6 +74,7 @@ export default function Customers() {
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{showAdd && (
|
||||
<tr>
|
||||
<td></td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
@ -78,6 +92,7 @@ export default function Customers() {
|
||||
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>
|
||||
@ -94,9 +109,10 @@ export default function Customers() {
|
||||
</tr>
|
||||
)}
|
||||
{customers.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-gray-800/50">
|
||||
<>
|
||||
{editId === c.id ? (
|
||||
<>
|
||||
<tr key={`edit-${c.id}`} className="bg-gray-800/30">
|
||||
<td></td>
|
||||
<td className="px-4 py-2">
|
||||
<input
|
||||
type="text"
|
||||
@ -124,30 +140,50 @@ export default function Customers() {
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
<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">
|
||||
<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"
|
||||
className="text-gray-500 hover:text-gray-300 p-1 rounded hover:bg-gray-700"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(c.id)} className="text-gray-500 hover:text-red-400">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
@ -158,3 +194,68 @@ export default function Customers() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomerKeyPanel({ customerId }) {
|
||||
const [keys, setKeys] = useState(null)
|
||||
const [copied, setCopied] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.getCustomerAPIKeys(customerId).then(setKeys)
|
||||
}, [customerId])
|
||||
|
||||
const copyKey = (key, id) => {
|
||||
navigator.clipboard.writeText(key).catch(() => {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = key; document.body.appendChild(el); el.select()
|
||||
document.execCommand('copy'); document.body.removeChild(el)
|
||||
})
|
||||
setCopied(id)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
if (keys === null) {
|
||||
return <div className="px-8 py-3 text-gray-500 text-xs bg-gray-800/20 border-t border-gray-800">Laden...</div>
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="px-8 py-3 text-gray-500 text-xs bg-gray-800/20 border-t border-gray-800 flex items-center gap-2">
|
||||
<Key className="w-3.5 h-3.5" />
|
||||
Keine API-Keys fuer diesen Kunden
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/20 border-t border-gray-800 px-8 py-3 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.map((k) => {
|
||||
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
||||
return (
|
||||
<div key={k.id} className="flex items-center gap-3">
|
||||
<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>
|
||||
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">{k.name}</span>
|
||||
<code className="text-xs text-gray-500 font-mono flex-shrink-0">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user