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:
parent
91385b42d1
commit
ec30be7246
@ -24,25 +24,37 @@ func (h *Handler) listAPIKeys(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Permissions string `json:"permissions"` // "agent" | "read" | "write" | "admin"
|
||||||
|
CustomerID *int `json:"customer_id"` // optional, null = global
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
|
writeError(w, http.StatusBadRequest, "Parameter 'name' erforderlich")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permissions validieren, default: "read"
|
||||||
|
validPerms := map[string]bool{"agent": true, "read": true, "write": true, "admin": true}
|
||||||
|
if req.Permissions == "" {
|
||||||
|
req.Permissions = "read"
|
||||||
|
}
|
||||||
|
if !validPerms[req.Permissions] {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungueltige Permissions (agent|read|write|admin)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Zufaelligen 32-Byte Key generieren
|
// Zufaelligen 32-Byte Key generieren
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
rand.Read(b)
|
rand.Read(b)
|
||||||
key := hex.EncodeToString(b)
|
key := hex.EncodeToString(b)
|
||||||
|
|
||||||
apiKey, err := h.db.CreateAPIKey(req.Name, key)
|
apiKey, err := h.db.CreateAPIKey(req.Name, key, req.Permissions, req.CustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
|
log.Printf("API-Key erstellen fehlgeschlagen: %v", err)
|
||||||
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
|
writeError(w, http.StatusInternalServerError, "Erstellen fehlgeschlagen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Neuer API-Key erstellt: %s (%s...)", apiKey.Name, key[:8])
|
log.Printf("Neuer API-Key erstellt: %s (%s...) perm=%s", apiKey.Name, key[:8], apiKey.Permissions)
|
||||||
writeJSON(w, http.StatusCreated, apiKey)
|
writeJSON(w, http.StatusCreated, apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,18 +8,21 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cynfo/rmm-backend/db"
|
db "github.com/cynfo/rmm-backend/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const UserContextKey contextKey = "user"
|
const UserContextKey contextKey = "user"
|
||||||
|
|
||||||
|
// APIKeyContextKey fuer Key-Infos im Request-Context
|
||||||
|
const APIKeyContextKey contextKey = "apikey"
|
||||||
|
|
||||||
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
// APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh
|
||||||
type APIKeyCache struct {
|
type APIKeyCache struct {
|
||||||
db *db.Database
|
db *db.Database
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
keys map[string]bool
|
keys map[string]*db.APIKey // key -> APIKey mit Permissions + CustomerID
|
||||||
lastLoad time.Time
|
lastLoad time.Time
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
}
|
}
|
||||||
@ -27,7 +30,7 @@ type APIKeyCache struct {
|
|||||||
func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
||||||
c := &APIKeyCache{
|
c := &APIKeyCache{
|
||||||
db: database,
|
db: database,
|
||||||
keys: make(map[string]bool),
|
keys: make(map[string]*db.APIKey),
|
||||||
ttl: 30 * time.Second,
|
ttl: 30 * time.Second,
|
||||||
}
|
}
|
||||||
c.refresh()
|
c.refresh()
|
||||||
@ -35,14 +38,15 @@ func NewAPIKeyCache(database *db.Database) *APIKeyCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *APIKeyCache) refresh() {
|
func (c *APIKeyCache) refresh() {
|
||||||
dbKeys, err := c.db.GetAllAPIKeys()
|
dbKeys, err := c.db.ListAPIKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
|
log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newKeys := make(map[string]bool, len(dbKeys))
|
newKeys := make(map[string]*db.APIKey, len(dbKeys))
|
||||||
for _, k := range dbKeys {
|
for i := range dbKeys {
|
||||||
newKeys[k] = true
|
k := dbKeys[i]
|
||||||
|
newKeys[k.Key] = &k
|
||||||
}
|
}
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.keys = newKeys
|
c.keys = newKeys
|
||||||
@ -50,27 +54,32 @@ func (c *APIKeyCache) refresh() {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *APIKeyCache) IsValid(key string) bool {
|
func (c *APIKeyCache) Get(key string) *db.APIKey {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
expired := time.Since(c.lastLoad) > c.ttl
|
expired := time.Since(c.lastLoad) > c.ttl
|
||||||
valid := c.keys[key]
|
info := c.keys[key]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
if expired {
|
if expired {
|
||||||
c.refresh()
|
c.refresh()
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
valid = c.keys[key]
|
info = c.keys[key]
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if valid {
|
if info != nil {
|
||||||
go c.db.UpdateAPIKeyLastUsed(key)
|
go c.db.UpdateAPIKeyLastUsed(key)
|
||||||
}
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
return valid
|
// IsValid prueft nur ob Key existiert (Abwaertskompatibilitaet)
|
||||||
|
func (c *APIKeyCache) IsValid(key string) bool {
|
||||||
|
return c.Get(key) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
|
// CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token
|
||||||
|
// Setzt APIKey-Info im Context fuer nachgelagerte Permission-Checks.
|
||||||
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
|
func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Try API key first (Header oder Query-Parameter)
|
// Try API key first (Header oder Query-Parameter)
|
||||||
@ -78,10 +87,18 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
|
|||||||
if key == "" {
|
if key == "" {
|
||||||
key = r.URL.Query().Get("api_key")
|
key = r.URL.Query().Get("api_key")
|
||||||
}
|
}
|
||||||
if key != "" && cache.IsValid(key) {
|
if key != "" {
|
||||||
next.ServeHTTP(w, r)
|
if info := cache.Get(key); info != nil {
|
||||||
|
// Permission-Check
|
||||||
|
if !hasAPIKeyPermission(info, r) {
|
||||||
|
http.Error(w, `{"error":"forbidden: unzureichende Berechtigungen"}`, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), APIKeyContextKey, info)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket)
|
// Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket)
|
||||||
var jwtToken string
|
var jwtToken string
|
||||||
@ -104,6 +121,60 @@ func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Hand
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasAPIKeyPermission prueft ob ein API-Key fuer den aktuellen Request berechtigt ist
|
||||||
|
func hasAPIKeyPermission(key *db.APIKey, r *http.Request) bool {
|
||||||
|
perm := key.Permissions
|
||||||
|
method := r.Method
|
||||||
|
path := r.URL.Path
|
||||||
|
|
||||||
|
switch perm {
|
||||||
|
case "agent":
|
||||||
|
// Nur Agent-Endpunkte: WS-Connect und Heartbeat
|
||||||
|
return path == "/api/v1/agent/ws" || path == "/api/v1/agent/heartbeat"
|
||||||
|
|
||||||
|
case "read":
|
||||||
|
// Nur GET-Requests, keine destruktiven Aktionen
|
||||||
|
if method != http.MethodGet {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Customer-Scope pruefen (falls gesetzt)
|
||||||
|
return checkCustomerScope(key, r)
|
||||||
|
|
||||||
|
case "write":
|
||||||
|
// Alles ausser Terminal (Terminal ist JWT-only)
|
||||||
|
// Customer-Scope pruefen
|
||||||
|
return checkCustomerScope(key, r)
|
||||||
|
|
||||||
|
case "admin":
|
||||||
|
// Vollen Zugriff (kein Terminal — das prueft JWTOnlyAuth separat)
|
||||||
|
return true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCustomerScope prueft ob ein customer-scoped Key nur auf seinen Kunden zugreift
|
||||||
|
func checkCustomerScope(key *db.APIKey, r *http.Request) bool {
|
||||||
|
if key.CustomerID == nil {
|
||||||
|
return true // Kein Scope = globaler Zugriff
|
||||||
|
}
|
||||||
|
// Customer-ID aus dem Pfad extrahieren (z.B. /api/v1/agents/{id} -> Agent-Customer pruefen)
|
||||||
|
// Fuer jetzt: customer-scoped Keys werden im Handler weiter eingeschraenkt (via GetAPIKeyFromContext)
|
||||||
|
// Der eigentliche Scope-Check passiert in den Handlern die GetAPIKeyFromContext nutzen
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAPIKeyFromContext gibt den API-Key aus dem Context zurueck (nil wenn JWT-Auth)
|
||||||
|
func GetAPIKeyFromContext(r *http.Request) *db.APIKey {
|
||||||
|
if v := r.Context().Value(APIKeyContextKey); v != nil {
|
||||||
|
if k, ok := v.(*db.APIKey); ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
|
// CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback)
|
||||||
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler {
|
||||||
keySet := make(map[string]bool, len(validKeys))
|
keySet := make(map[string]bool, len(validKeys))
|
||||||
|
|||||||
@ -201,6 +201,11 @@ func (d *Database) migrate() error {
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
last_used TIMESTAMPTZ
|
last_used TIMESTAMPTZ
|
||||||
)`)
|
)`)
|
||||||
|
// Migration: customer_id + permissions Spalten hinzufuegen falls nicht vorhanden
|
||||||
|
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL`)
|
||||||
|
d.db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS permissions TEXT NOT NULL DEFAULT 'admin'`)
|
||||||
|
// Bestehende Keys auf 'admin' setzen (Abwaertskompatibilitaet)
|
||||||
|
d.db.Exec(`UPDATE api_keys SET permissions = 'admin' WHERE permissions = ''`)
|
||||||
|
|
||||||
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
|
// Installer-Pakete (install.sh + Plugin als ZIP pro Plattform)
|
||||||
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS installers (
|
||||||
@ -1135,16 +1140,23 @@ func (d *Database) GetAgentStatus(agentID string) string {
|
|||||||
|
|
||||||
// ==================== API-Keys ====================
|
// ==================== API-Keys ====================
|
||||||
|
|
||||||
|
// Permissions: "agent" | "read" | "write" | "admin"
|
||||||
|
// - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat (fuer Agents auf Kundenservern)
|
||||||
|
// - read: GET-Endpoints, optional auf customer_id beschraenkt
|
||||||
|
// - write: alles ausser Terminal (fuer externe Integrationen)
|
||||||
|
// - admin: voll (kein Terminal — das ist JWT-only)
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
|
Permissions string `json:"permissions"`
|
||||||
|
CustomerID *int `json:"customer_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
||||||
rows, err := d.db.Query("SELECT id, name, key, created_at, last_used FROM api_keys ORDER BY created_at")
|
rows, err := d.db.Query("SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys ORDER BY created_at")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -1154,12 +1166,17 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var k APIKey
|
var k APIKey
|
||||||
var lastUsed sql.NullTime
|
var lastUsed sql.NullTime
|
||||||
if err := rows.Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt, &lastUsed); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if lastUsed.Valid {
|
if lastUsed.Valid {
|
||||||
k.LastUsed = &lastUsed.Time
|
k.LastUsed = &lastUsed.Time
|
||||||
}
|
}
|
||||||
|
if custID.Valid {
|
||||||
|
id := int(custID.Int64)
|
||||||
|
k.CustomerID = &id
|
||||||
|
}
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
if keys == nil {
|
if keys == nil {
|
||||||
@ -1168,15 +1185,24 @@ func (d *Database) ListAPIKeys() ([]APIKey, error) {
|
|||||||
return keys, rows.Err()
|
return keys, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) CreateAPIKey(name, key string) (*APIKey, error) {
|
func (d *Database) CreateAPIKey(name, key, permissions string, customerID *int) (*APIKey, error) {
|
||||||
var k APIKey
|
var k APIKey
|
||||||
|
var custID sql.NullInt64
|
||||||
|
if customerID != nil {
|
||||||
|
custID = sql.NullInt64{Int64: int64(*customerID), Valid: true}
|
||||||
|
}
|
||||||
|
var scannedCustID sql.NullInt64
|
||||||
err := d.db.QueryRow(
|
err := d.db.QueryRow(
|
||||||
"INSERT INTO api_keys (name, key) VALUES ($1, $2) RETURNING id, name, key, created_at",
|
"INSERT INTO api_keys (name, key, permissions, customer_id) VALUES ($1, $2, $3, $4) RETURNING id, name, key, permissions, customer_id, created_at",
|
||||||
name, key,
|
name, key, permissions, custID,
|
||||||
).Scan(&k.ID, &k.Name, &k.Key, &k.CreatedAt)
|
).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &scannedCustID, &k.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if scannedCustID.Valid {
|
||||||
|
id := int(scannedCustID.Int64)
|
||||||
|
k.CustomerID = &id
|
||||||
|
}
|
||||||
return &k, nil
|
return &k, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1192,6 +1218,25 @@ func (d *Database) DeleteAPIKey(id int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAPIKeyInfo gibt Key + Permissions + CustomerID zurueck (fuer Middleware)
|
||||||
|
func (d *Database) GetAPIKeyInfo(key string) (*APIKey, error) {
|
||||||
|
var k APIKey
|
||||||
|
var custID sql.NullInt64
|
||||||
|
var lastUsed sql.NullTime
|
||||||
|
err := d.db.QueryRow(
|
||||||
|
"SELECT id, name, key, permissions, customer_id, created_at, last_used FROM api_keys WHERE key = $1",
|
||||||
|
key,
|
||||||
|
).Scan(&k.ID, &k.Name, &k.Key, &k.Permissions, &custID, &k.CreatedAt, &lastUsed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if custID.Valid {
|
||||||
|
id := int(custID.Int64)
|
||||||
|
k.CustomerID = &id
|
||||||
|
}
|
||||||
|
return &k, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Database) GetAllAPIKeys() ([]string, error) {
|
func (d *Database) GetAllAPIKeys() ([]string, error) {
|
||||||
rows, err := d.db.Query("SELECT key FROM api_keys")
|
rows, err := d.db.Query("SELECT key FROM api_keys")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -250,8 +250,8 @@ class ApiClient {
|
|||||||
return this.get('/api/v1/apikeys')
|
return this.get('/api/v1/apikeys')
|
||||||
}
|
}
|
||||||
|
|
||||||
createAPIKey(name) {
|
createAPIKey(name, permissions = 'read', customerId = null) {
|
||||||
return this.post('/api/v1/apikeys', { name })
|
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteAPIKey(id) {
|
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() {
|
function APIKeysSection() {
|
||||||
const [keys, setKeys] = useState([])
|
const [keys, setKeys] = useState([])
|
||||||
|
const [customers, setCustomers] = useState([])
|
||||||
const [showAdd, setShowAdd] = useState(false)
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newPerms, setNewPerms] = useState('read')
|
||||||
|
const [newCustomer, setNewCustomer] = useState('')
|
||||||
const [newKey, setNewKey] = useState(null)
|
const [newKey, setNewKey] = useState(null)
|
||||||
const [copied, setCopied] = useState(null)
|
const [copied, setCopied] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
api.getAPIKeys()
|
Promise.all([api.getAPIKeys(), api.getCustomers()])
|
||||||
.then(k => setKeys(k || []))
|
.then(([k, c]) => { setKeys(k || []); setCustomers(c || []) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
useEffect(() => { reload() }, [])
|
useEffect(() => { reload() }, [])
|
||||||
@ -218,9 +228,11 @@ function APIKeysSection() {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newName.trim()) return
|
if (!newName.trim()) return
|
||||||
try {
|
try {
|
||||||
const result = await api.createAPIKey(newName.trim())
|
const result = await api.createAPIKey(newName.trim(), newPerms, newCustomer ? parseInt(newCustomer) : null)
|
||||||
setNewKey(result)
|
setNewKey(result)
|
||||||
setNewName('')
|
setNewName('')
|
||||||
|
setNewPerms('read')
|
||||||
|
setNewCustomer('')
|
||||||
reload()
|
reload()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fehler: ' + e.message)
|
alert('Fehler: ' + e.message)
|
||||||
@ -258,7 +270,8 @@ function APIKeysSection() {
|
|||||||
|
|
||||||
{/* Neuer Key Dialog */}
|
{/* Neuer Key Dialog */}
|
||||||
{showAdd && !newKey && (
|
{showAdd && !newKey && (
|
||||||
<div className="px-4 py-3 border-b border-gray-800 flex gap-2 items-end">
|
<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">
|
<div className="flex-1">
|
||||||
<label className="text-xs text-gray-500">Bezeichnung</label>
|
<label className="text-xs text-gray-500">Bezeichnung</label>
|
||||||
<input
|
<input
|
||||||
@ -271,13 +284,43 @@ function APIKeysSection() {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleCreate} className="px-3 py-1 bg-orange-600 hover:bg-orange-700 rounded text-sm text-white">
|
<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
|
Erstellen
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400">
|
<button onClick={() => setShowAdd(false)} className="px-3 py-1 bg-gray-800 rounded text-sm text-gray-400 self-end">
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{newPerms && PERM_LABELS[newPerms] && (
|
||||||
|
<div className="text-xs text-gray-500 pl-1">{PERM_LABELS[newPerms].desc}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Neuer Key Anzeige */}
|
{/* Neuer Key Anzeige */}
|
||||||
@ -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="px-4 py-6 text-center text-gray-500 text-sm">Keine API-Keys vorhanden</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-800">
|
<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 key={k.id} className="px-4 py-2.5 flex items-center justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-white">{k.name}</span>
|
<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>
|
||||||
<div className="flex items-center gap-3 mt-0.5">
|
<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>
|
<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>
|
</span>
|
||||||
{k.last_used && (
|
{k.last_used && (
|
||||||
<span className="text-xs text-gray-600">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -344,7 +398,8 @@ function APIKeysSection() {
|
|||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user