security: API-Key Permissions + Customer-Scoping
- DB Migration: api_keys bekommt 'permissions' + 'customer_id' Spalten - Permissions: agent | read | write | admin - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat - read: GET-Endpoints (optional customer-scoped) - write: alles ausser Terminal - admin: voll (Terminal bleibt JWT-only) - Bestehende Keys werden auf 'admin' migriert (Abwaertskompatibilitaet) - Frontend: API-Key Verwaltung zeigt Permissions-Badge + Kunden-Scope - API-Client: createAPIKey nimmt jetzt permissions + customer_id
This commit is contained in:
@@ -250,8 +250,8 @@ class ApiClient {
|
||||
return this.get('/api/v1/apikeys')
|
||||
}
|
||||
|
||||
createAPIKey(name) {
|
||||
return this.post('/api/v1/apikeys', { name })
|
||||
createAPIKey(name, permissions = 'read', customerId = null) {
|
||||
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
||||
}
|
||||
|
||||
deleteAPIKey(id) {
|
||||
|
||||
@@ -199,18 +199,28 @@ export default function SettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const PERM_LABELS = {
|
||||
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50', desc: 'Nur WS-Connect + Heartbeat' },
|
||||
read: { label: 'Read-only', color: 'text-green-400 bg-green-900/30 border-green-700/50', desc: 'GET-Endpoints, kein Schreiben' },
|
||||
write: { label: 'Write', color: 'text-yellow-400 bg-yellow-900/30 border-yellow-700/50', desc: 'Lesen + Schreiben, kein Terminal' },
|
||||
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50', desc: 'Voll (kein Terminal)' },
|
||||
}
|
||||
|
||||
function APIKeysSection() {
|
||||
const [keys, setKeys] = useState([])
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newPerms, setNewPerms] = useState('read')
|
||||
const [newCustomer, setNewCustomer] = 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 || []))
|
||||
Promise.all([api.getAPIKeys(), api.getCustomers()])
|
||||
.then(([k, c]) => { setKeys(k || []); setCustomers(c || []) })
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
useEffect(() => { reload() }, [])
|
||||
@@ -218,9 +228,11 @@ function APIKeysSection() {
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return
|
||||
try {
|
||||
const result = await api.createAPIKey(newName.trim())
|
||||
const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null)
|
||||
setNewKey(result)
|
||||
setNewName('')
|
||||
setNewPerms('read')
|
||||
setNewCustomer('')
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
@@ -258,25 +270,56 @@ function APIKeysSection() {
|
||||
|
||||
{/* 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 className="px-4 py-3 border-b border-gray-800 space-y-2">
|
||||
<div className="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>
|
||||
<div className="w-36">
|
||||
<label className="text-xs text-gray-500">Berechtigung</label>
|
||||
<select
|
||||
value={newPerms}
|
||||
onChange={(e) => setNewPerms(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"
|
||||
>
|
||||
<option value="agent">Agent (nur WS)</option>
|
||||
<option value="read">Read-only</option>
|
||||
<option value="write">Write</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<label className="text-xs text-gray-500">Kunde (optional)</label>
|
||||
<select
|
||||
value={newCustomer}
|
||||
onChange={(e) => setNewCustomer(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"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.number} — {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white self-end">
|
||||
Erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 self-end">
|
||||
Abbrechen
|
||||
</button>
|
||||
</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>
|
||||
{newPerms && PERM_LABELS[newPerms] && (
|
||||
<div className="text-xs text-gray-500 pl-1">{PERM_LABELS[newPerms].desc}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -311,11 +354,22 @@ function APIKeysSection() {
|
||||
<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) => (
|
||||
{keys.map((k) => {
|
||||
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
||||
const customer = k.customer_id ? customers.find(c => c.id === k.customer_id) : null
|
||||
return (
|
||||
<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>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none ${perm.color}`}>
|
||||
{perm.label}
|
||||
</span>
|
||||
{customer && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border text-gray-400 bg-gray-800 border-gray-700 leading-none">
|
||||
{customer.number}
|
||||
</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>
|
||||
@@ -331,7 +385,7 @@ function APIKeysSection() {
|
||||
</span>
|
||||
{k.last_used && (
|
||||
<span className="text-xs text-gray-600">
|
||||
Letzter Zugriff: {new Date(k.last_used).toLocaleString('de-DE')}
|
||||
Zuletzt: {new Date(k.last_used).toLocaleString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -344,7 +398,8 @@ function APIKeysSection() {
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user