push newest version
This commit is contained in:
198
DB_COMMANDS.md
Normal file
198
DB_COMMANDS.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# SQLite Datenbank-Befehle
|
||||||
|
|
||||||
|
## Verbindung zur Datenbank
|
||||||
|
|
||||||
|
Die Datenbank liegt in: `./backend/spaces.db`
|
||||||
|
|
||||||
|
### Verbindung herstellen:
|
||||||
|
```bash
|
||||||
|
cd /home/nick/Development/certigo-addon/backend
|
||||||
|
sqlite3 spaces.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nützliche SQLite-Befehle
|
||||||
|
|
||||||
|
### Tabellen auflisten:
|
||||||
|
```sql
|
||||||
|
.tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema einer Tabelle anzeigen:
|
||||||
|
```sql
|
||||||
|
.schema audit_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alle Audit-Logs anzeigen:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anzahl der Audit-Logs:
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) FROM audit_logs;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Letzte 5 Audit-Logs mit Details:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
timestamp,
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
details
|
||||||
|
FROM audit_logs
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 5;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Einen spezifischen Audit-Log anzeigen (nach ID):
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs WHERE id = 'LOG_ID_HIER';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Einen spezifischen Audit-Log anzeigen (nach Timestamp):
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs WHERE timestamp = '2025-11-20 16:20:00';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit-Logs nach Aktion filtern:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs WHERE action = 'CREATE' ORDER BY timestamp DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit-Logs nach Ressourcentyp filtern:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs WHERE resource_type = 'user' ORDER BY timestamp DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Audit-Logs nach Benutzer filtern:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs WHERE username = 'admin' ORDER BY timestamp DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vollständige Details eines Logs (formatiert):
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
timestamp,
|
||||||
|
user_id,
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
resource_id,
|
||||||
|
details,
|
||||||
|
ip_address,
|
||||||
|
user_agent
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE id = 'LOG_ID_HIER';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neueste Logs mit formatierten Details:
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
datetime(timestamp) as zeit,
|
||||||
|
username,
|
||||||
|
action,
|
||||||
|
resource_type,
|
||||||
|
json_extract(details, '$.message') as nachricht,
|
||||||
|
ip_address
|
||||||
|
FROM audit_logs
|
||||||
|
ORDER BY datetime(timestamp) DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs der letzten Stunde:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs
|
||||||
|
WHERE datetime(timestamp) >= datetime('now', '-1 hour')
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs von heute:
|
||||||
|
```sql
|
||||||
|
SELECT * FROM audit_logs
|
||||||
|
WHERE date(timestamp) = date('now')
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alle Tabellen auflisten:
|
||||||
|
```sql
|
||||||
|
SELECT name FROM sqlite_master WHERE type='table';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prüfen ob audit_logs Tabelle existiert:
|
||||||
|
```sql
|
||||||
|
SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alle Spalten der audit_logs Tabelle:
|
||||||
|
```sql
|
||||||
|
PRAGMA table_info(audit_logs);
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQLite verlassen:
|
||||||
|
```sql
|
||||||
|
.quit
|
||||||
|
```
|
||||||
|
oder
|
||||||
|
```sql
|
||||||
|
.exit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Direkte Befehle (ohne interaktive Shell)
|
||||||
|
|
||||||
|
### Anzahl der Logs:
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT COUNT(*) FROM audit_logs;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Letzte 5 Logs:
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT timestamp, username, action, resource_type, details FROM audit_logs ORDER BY timestamp DESC LIMIT 5;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prüfen ob Tabelle existiert:
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alle Logs als CSV exportieren:
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -csv backend/spaces.db "SELECT * FROM audit_logs ORDER BY timestamp DESC;" > audit_logs.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Einen spezifischen Log anzeigen (Beispiel):
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT * FROM audit_logs WHERE id = '5d424293-14c1-48ef-a34c-5555d843d289';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Neueste 10 Logs mit Details:
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -column backend/spaces.db "SELECT timestamp, username, action, resource_type, details FROM audit_logs ORDER BY timestamp DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs nach Aktion filtern:
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -column backend/spaces.db "SELECT * FROM audit_logs WHERE action = 'CREATE' ORDER BY timestamp DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs nach Benutzer filtern:
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -column backend/spaces.db "SELECT * FROM audit_logs WHERE username = 'admin' ORDER BY timestamp DESC LIMIT 10;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs von heute:
|
||||||
|
```bash
|
||||||
|
sqlite3 -header -column backend/spaces.db "SELECT * FROM audit_logs WHERE date(timestamp) = date('now') ORDER BY timestamp DESC;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON-Details eines Logs formatiert anzeigen:
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT json_pretty(details) FROM audit_logs WHERE id = 'LOG_ID_HIER';"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis**: Falls `json_pretty` nicht verfügbar ist, verwende stattdessen:
|
||||||
|
```bash
|
||||||
|
sqlite3 backend/spaces.db "SELECT details FROM audit_logs WHERE id = 'LOG_ID_HIER';" | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
module certigo-addon-backend
|
module certigo-addon-backend
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/uuid v1.5.0
|
github.com/google/uuid v1.5.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.18
|
github.com/mattn/go-sqlite3 v1.14.18
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require golang.org/x/crypto v0.45.0 // indirect
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
|
|||||||
126
backend/internal/core/audit.go
Normal file
126
backend/internal/core/audit.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
var berlinLocation *time.Location
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var err error
|
||||||
|
berlinLocation, err = time.LoadLocation("Europe/Berlin")
|
||||||
|
if err != nil {
|
||||||
|
// Fallback zu UTC falls Europe/Berlin nicht verfügbar ist
|
||||||
|
log.Printf("Warnung: Europe/Berlin Zeitzone nicht verfügbar, verwende UTC: %v", err)
|
||||||
|
berlinLocation = time.UTC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditService handles audit logging
|
||||||
|
type AuditService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuditService creates a new AuditService instance
|
||||||
|
func NewAuditService(db *sql.DB) *AuditService {
|
||||||
|
return &AuditService{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track logs an audit event asynchronously
|
||||||
|
// ctx: context for the operation
|
||||||
|
// action: the action performed (e.g., "CREATE", "UPDATE", "DELETE")
|
||||||
|
// entity: the entity type (e.g., "user", "space", "fqdn", "csr")
|
||||||
|
// entityID: the ID of the entity (optional)
|
||||||
|
// userID: the ID of the user performing the action (optional)
|
||||||
|
// username: the username of the user performing the action (optional)
|
||||||
|
// details: additional details as a map (will be stored as JSON)
|
||||||
|
// ipAddress: IP address of the request (optional)
|
||||||
|
// userAgent: User-Agent header (optional)
|
||||||
|
func (s *AuditService) Track(ctx context.Context, action, entity, entityID, userID, username string, details map[string]interface{}, ipAddress, userAgent string) {
|
||||||
|
// Check if service is nil (should not happen, but safety check)
|
||||||
|
if s == nil {
|
||||||
|
log.Printf("Warnung: AuditService ist nil, kann kein Audit-Log schreiben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database is available
|
||||||
|
if s.db == nil {
|
||||||
|
log.Printf("Warnung: Datenbank ist nil im AuditService, kann kein Audit-Log schreiben")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute asynchronously in a goroutine to not block the main operation
|
||||||
|
go func() {
|
||||||
|
if err := s.trackSync(ctx, action, entity, entityID, userID, username, details, ipAddress, userAgent); err != nil {
|
||||||
|
// Log errors but don't fail the main operation
|
||||||
|
log.Printf("Fehler beim Schreiben des Audit-Logs: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackSync performs the actual database write synchronously
|
||||||
|
func (s *AuditService) trackSync(ctx context.Context, action, entity, entityID, userID, username string, details map[string]interface{}, ipAddress, userAgent string) error {
|
||||||
|
// Safety check: ensure service and database are not nil
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("AuditService ist nil")
|
||||||
|
}
|
||||||
|
if s.db == nil {
|
||||||
|
return fmt.Errorf("Datenbank ist nil im AuditService")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique ID for the log entry
|
||||||
|
logID := uuid.New().String()
|
||||||
|
|
||||||
|
// Format timestamp for SQLite - verwende Europe/Berlin Zeitzone
|
||||||
|
// Speichere als ISO-String mit Zeitzone für bessere Kompatibilität
|
||||||
|
// Format: YYYY-MM-DD HH:MM:SS (SQLite DATETIME Format)
|
||||||
|
// Die Zeit wird in Europe/Berlin Zeitzone gespeichert
|
||||||
|
now := time.Now().In(berlinLocation)
|
||||||
|
timestamp := now.Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
// Marshal details to JSON
|
||||||
|
var detailsJSON string
|
||||||
|
if details != nil && len(details) > 0 {
|
||||||
|
jsonBytes, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Fehler beim Marshalling der Details: %w", err)
|
||||||
|
}
|
||||||
|
detailsJSON = string(jsonBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with timeout for database operation
|
||||||
|
dbCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Insert audit log entry
|
||||||
|
result, err := s.db.ExecContext(dbCtx,
|
||||||
|
"INSERT INTO audit_logs (id, timestamp, user_id, username, action, resource_type, resource_id, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
logID, timestamp, userID, username, action, entity, entityID, detailsJSON, ipAddress, userAgent)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Fehler beim INSERT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the insert was successful
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Fehler beim Prüfen der RowsAffected: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return fmt.Errorf("Keine Zeile eingefügt")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Audit-Log geschrieben: %s %s %s (ID: %s)", action, entity, entityID, logID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
1421
backend/main.go
1421
backend/main.go
File diff suppressed because it is too large
Load Diff
42
backend/scripts/README.md
Normal file
42
backend/scripts/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Test-Skripte für Audit-Logs
|
||||||
|
|
||||||
|
## Test-Logs generieren
|
||||||
|
|
||||||
|
Das Skript `generate_test_logs.go` erstellt 3000 Test-Audit-Logs für Testzwecke.
|
||||||
|
|
||||||
|
### Verwendung:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend/scripts
|
||||||
|
go run generate_test_logs.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguration:
|
||||||
|
|
||||||
|
Das Skript verwendet standardmäßig:
|
||||||
|
- URL: `http://localhost:8080`
|
||||||
|
- Username: `admin`
|
||||||
|
- Password: `admin`
|
||||||
|
|
||||||
|
Diese können im Skript geändert werden, falls nötig.
|
||||||
|
|
||||||
|
### Was wird erstellt:
|
||||||
|
|
||||||
|
- 3000 verschiedene Audit-Log-Einträge
|
||||||
|
- Verschiedene Aktionen: CREATE, UPDATE, DELETE, UPLOAD, SIGN, ENABLE, DISABLE
|
||||||
|
- Verschiedene Ressourcentypen: user, space, fqdn, csr, provider, certificate
|
||||||
|
- Realistische Testdaten mit verschiedenen Details
|
||||||
|
- Fortschrittsanzeige alle 100 Logs
|
||||||
|
|
||||||
|
## Alle Logs löschen
|
||||||
|
|
||||||
|
Verwende die API, um alle Audit-Logs zu löschen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE "http://localhost:8080/api/audit-logs?confirm=true" \
|
||||||
|
-u admin:admin \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig**: Der `confirm=true` Query-Parameter ist erforderlich, um versehentliches Löschen zu verhindern.
|
||||||
|
|
||||||
132
backend/scripts/generate_test_logs.go
Normal file
132
backend/scripts/generate_test_logs.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
baseURL := "http://localhost:8080"
|
||||||
|
username := "admin"
|
||||||
|
password := "admin"
|
||||||
|
|
||||||
|
// Erstelle Basic Auth Header
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
|
||||||
|
|
||||||
|
// Verschiedene Aktionen und Ressourcen für realistische Testdaten
|
||||||
|
actions := []string{"CREATE", "UPDATE", "DELETE", "UPLOAD", "SIGN", "ENABLE", "DISABLE"}
|
||||||
|
resourceTypes := []string{"user", "space", "fqdn", "csr", "provider", "certificate"}
|
||||||
|
usernames := []string{"admin", "user1", "user2", "operator", "manager"}
|
||||||
|
|
||||||
|
fmt.Printf("Generiere 3000 Test-Audit-Logs...\n")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < 3000; i++ {
|
||||||
|
// Wähle zufällige Werte für realistische Testdaten
|
||||||
|
action := actions[i%len(actions)]
|
||||||
|
resourceType := resourceTypes[i%len(resourceTypes)]
|
||||||
|
username := usernames[i%len(usernames)]
|
||||||
|
|
||||||
|
// Erstelle Details mit verschiedenen Informationen
|
||||||
|
details := map[string]interface{}{
|
||||||
|
"message": fmt.Sprintf("Test-Log Eintrag #%d", i+1),
|
||||||
|
"iteration": i + 1,
|
||||||
|
"timestamp": time.Now().Format(time.RFC3339),
|
||||||
|
"testData": true,
|
||||||
|
"resourceId": fmt.Sprintf("test-resource-%d", i+1),
|
||||||
|
"description": fmt.Sprintf("Dies ist ein Test-Log-Eintrag für %s %s", action, resourceType),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Füge spezifische Details basierend auf Resource-Type hinzu
|
||||||
|
switch resourceType {
|
||||||
|
case "user":
|
||||||
|
details["username"] = fmt.Sprintf("testuser%d", i+1)
|
||||||
|
details["email"] = fmt.Sprintf("test%d@example.com", i+1)
|
||||||
|
case "space":
|
||||||
|
details["name"] = fmt.Sprintf("Test Space %d", i+1)
|
||||||
|
details["description"] = "Test Space Description"
|
||||||
|
case "fqdn":
|
||||||
|
details["fqdn"] = fmt.Sprintf("test%d.example.com", i+1)
|
||||||
|
details["spaceId"] = fmt.Sprintf("space-%d", i%100)
|
||||||
|
case "csr":
|
||||||
|
details["fqdnId"] = fmt.Sprintf("fqdn-%d", i%200)
|
||||||
|
details["keySize"] = 2048
|
||||||
|
case "provider":
|
||||||
|
details["providerId"] = fmt.Sprintf("provider-%d", i%10)
|
||||||
|
details["enabled"] = i%2 == 0
|
||||||
|
case "certificate":
|
||||||
|
details["certificateId"] = fmt.Sprintf("cert-%d", i+1)
|
||||||
|
details["status"] = "issued"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Request Body
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"entity": resourceType,
|
||||||
|
"entityID": fmt.Sprintf("test-id-%d", i+1),
|
||||||
|
"userID": fmt.Sprintf("user-id-%d", i%5+1),
|
||||||
|
"username": username,
|
||||||
|
"details": details,
|
||||||
|
"ipAddress": fmt.Sprintf("192.168.1.%d", i%255+1),
|
||||||
|
"userAgent": "Test-Script/1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Fehler beim Marshalling: %v", err)
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle HTTP Request
|
||||||
|
req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/audit-logs/test", baseURL), bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Fehler beim Erstellen des Requests: %v", err)
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth))
|
||||||
|
|
||||||
|
// Sende Request
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Fehler beim Senden des Requests: %v", err)
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||||
|
successCount++
|
||||||
|
if (i+1)%100 == 0 {
|
||||||
|
fmt.Printf("Progress: %d/3000 Logs erstellt\n", i+1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorCount++
|
||||||
|
log.Printf("Unerwarteter Status Code: %d für Log %d", resp.StatusCode, i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kleine Pause, um die Datenbank nicht zu überlasten
|
||||||
|
if i%50 == 0 && i > 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nFertig!\n")
|
||||||
|
fmt.Printf("Erfolgreich: %d\n", successCount)
|
||||||
|
fmt.Printf("Fehler: %d\n", errorCount)
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
BIN
backend/uploads/avatars/7124facd-9b11-40d1-83b1-ca747f2a8b0f.png
Normal file
BIN
backend/uploads/avatars/7124facd-9b11-40d1-83b1-ca747f2a8b0f.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -1,33 +1,92 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import Spaces from './pages/Spaces'
|
import Spaces from './pages/Spaces'
|
||||||
import SpaceDetail from './pages/SpaceDetail'
|
import SpaceDetail from './pages/SpaceDetail'
|
||||||
import Impressum from './pages/Impressum'
|
import Impressum from './pages/Impressum'
|
||||||
|
import Profile from './pages/Profile'
|
||||||
|
import Users from './pages/Users'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import AuditLogs from './pages/AuditLogs'
|
||||||
|
|
||||||
function App() {
|
// Protected Route Component
|
||||||
|
const ProtectedRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-slate-300">Lade...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public Route Component (redirects to home if already logged in)
|
||||||
|
const PublicRoute = ({ children }) => {
|
||||||
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-slate-300">Lade...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isAuthenticated ? children : <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContent = () => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
||||||
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<Sidebar isOpen={sidebarOpen} setIsOpen={setSidebarOpen} />
|
||||||
<Sidebar isOpen={sidebarOpen} setIsOpen={setSidebarOpen} />
|
<main className="flex-1 overflow-y-auto flex flex-col bg-gradient-to-r from-slate-700 to-slate-900">
|
||||||
<main className="flex-1 overflow-y-auto flex flex-col bg-gradient-to-r from-slate-700 to-slate-900">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<Routes>
|
||||||
<Routes>
|
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||||
<Route path="/spaces" element={<Spaces />} />
|
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} />
|
||||||
<Route path="/spaces/:id" element={<SpaceDetail />} />
|
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} />
|
||||||
<Route path="/impressum" element={<Impressum />} />
|
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} />
|
||||||
</Routes>
|
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||||
</div>
|
<Route path="/settings/users" element={<ProtectedRoute><Users /></ProtectedRoute>} />
|
||||||
<Footer />
|
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} />
|
||||||
</main>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
<Footer />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
</Router>
|
</Router>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const ProvidersSection = () => {
|
const ProvidersSection = () => {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showConfigModal, setShowConfigModal] = useState(false)
|
const [showConfigModal, setShowConfigModal] = useState(false)
|
||||||
@@ -11,11 +13,11 @@ const ProvidersSection = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProviders()
|
fetchProviders()
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
const fetchProviders = async () => {
|
const fetchProviders = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/providers')
|
const response = await authFetch('/api/providers')
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Definiere feste Reihenfolge der Provider
|
// Definiere feste Reihenfolge der Provider
|
||||||
@@ -35,7 +37,7 @@ const ProvidersSection = () => {
|
|||||||
|
|
||||||
const handleToggleProvider = async (providerId, currentEnabled) => {
|
const handleToggleProvider = async (providerId, currentEnabled) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/providers/${providerId}/enabled`, {
|
const response = await authFetch(`/api/providers/${providerId}/enabled`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -60,7 +62,7 @@ const ProvidersSection = () => {
|
|||||||
|
|
||||||
// Lade aktuelle Konfiguration
|
// Lade aktuelle Konfiguration
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/providers/${provider.id}`)
|
const response = await authFetch(`/api/providers/${provider.id}`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Initialisiere Config-Werte
|
// Initialisiere Config-Werte
|
||||||
@@ -109,7 +111,7 @@ const ProvidersSection = () => {
|
|||||||
setTestResult(null)
|
setTestResult(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/providers/${selectedProvider.id}/test`, {
|
const response = await authFetch(`/api/providers/${selectedProvider.id}/test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -134,7 +136,7 @@ const ProvidersSection = () => {
|
|||||||
if (!selectedProvider) return
|
if (!selectedProvider) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/providers/${selectedProvider.id}/config`, {
|
const response = await authFetch(`/api/providers/${selectedProvider.id}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -1,14 +1,32 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
const Sidebar = ({ isOpen, setIsOpen }) => {
|
const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const [expandedMenus, setExpandedMenus] = useState({})
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ path: '/', label: 'Home', icon: '🏠' },
|
{ path: '/', label: 'Home', icon: '🏠' },
|
||||||
{ path: '/spaces', label: 'Spaces', icon: '📁' },
|
{ path: '/spaces', label: 'Spaces', icon: '📁' },
|
||||||
|
{ path: '/audit-logs', label: 'Audit Log', icon: '📋' },
|
||||||
{ path: '/impressum', label: 'Impressum', icon: 'ℹ️' },
|
{ path: '/impressum', label: 'Impressum', icon: 'ℹ️' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Settings mit Unterpunkten
|
||||||
|
const settingsMenu = {
|
||||||
|
label: 'Settings',
|
||||||
|
icon: '⚙️',
|
||||||
|
path: '/settings',
|
||||||
|
subItems: [
|
||||||
|
{ path: '/settings/users', label: 'User', icon: '👥' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileItem = { path: '/profile', label: 'Profil', icon: '👤' }
|
||||||
|
|
||||||
const isActive = (path) => {
|
const isActive = (path) => {
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
return location.pathname === '/'
|
return location.pathname === '/'
|
||||||
@@ -16,6 +34,27 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
return location.pathname.startsWith(path)
|
return location.pathname.startsWith(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (menuPath) => {
|
||||||
|
setExpandedMenus(prev => ({
|
||||||
|
...prev,
|
||||||
|
[menuPath]: !prev[menuPath]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMenuExpanded = (menuPath) => {
|
||||||
|
return expandedMenus[menuPath] || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname.startsWith('/settings')) {
|
||||||
|
setExpandedMenus(prev => ({
|
||||||
|
...prev,
|
||||||
|
'/settings': true
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Overlay for mobile */}
|
{/* Overlay for mobile */}
|
||||||
@@ -63,8 +102,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="px-2 py-4 overflow-hidden">
|
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2 flex-1">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<Link
|
<Link
|
||||||
@@ -87,7 +126,104 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Settings Menu mit Unterpunkten */}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={() => isOpen && toggleMenu(settingsMenu.path)}
|
||||||
|
className={`w-full flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive(settingsMenu.path)
|
||||||
|
? 'bg-slate-700 text-white font-semibold shadow-md'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title={!isOpen ? settingsMenu.label : ''}
|
||||||
|
>
|
||||||
|
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
|
||||||
|
{settingsMenu.icon}
|
||||||
|
</span>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<span className="whitespace-nowrap overflow-hidden">
|
||||||
|
{settingsMenu.label}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 ml-auto flex-shrink-0 transition-transform duration-200 ${
|
||||||
|
isMenuExpanded(settingsMenu.path) ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isOpen && isMenuExpanded(settingsMenu.path) && settingsMenu.subItems && (
|
||||||
|
<ul className="ml-4 mt-1 space-y-1">
|
||||||
|
{settingsMenu.subItems.map((subItem) => (
|
||||||
|
<li key={subItem.path}>
|
||||||
|
<Link
|
||||||
|
to={subItem.path}
|
||||||
|
className={`flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive(subItem.path)
|
||||||
|
? 'bg-slate-600 text-white font-semibold'
|
||||||
|
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-lg flex-shrink-0 mr-2">
|
||||||
|
{subItem.icon}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap overflow-hidden">
|
||||||
|
{subItem.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
{/* Profil-Eintrag und Logout am unteren Ende */}
|
||||||
|
<div className="mt-auto pt-2 border-t border-slate-700/50 space-y-2">
|
||||||
|
<Link
|
||||||
|
to={profileItem.path}
|
||||||
|
className={`flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive(profileItem.path)
|
||||||
|
? 'bg-slate-700 text-white font-semibold shadow-md'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title={!isOpen ? (user?.username || profileItem.label) : ''}
|
||||||
|
>
|
||||||
|
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
|
||||||
|
{profileItem.icon}
|
||||||
|
</span>
|
||||||
|
{isOpen && (
|
||||||
|
<span className="whitespace-nowrap overflow-hidden">
|
||||||
|
{user?.username || profileItem.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center px-3 py-3 rounded-lg transition-all duration-200 text-slate-300 hover:bg-red-600/20 hover:text-red-400 ${
|
||||||
|
isOpen ? '' : 'justify-center'
|
||||||
|
}`}
|
||||||
|
title={!isOpen ? 'Abmelden' : ''}
|
||||||
|
>
|
||||||
|
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : ''}`}>
|
||||||
|
🚪
|
||||||
|
</span>
|
||||||
|
{isOpen && (
|
||||||
|
<span className="whitespace-nowrap overflow-hidden">
|
||||||
|
Abmelden
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
150
frontend/src/contexts/AuthContext.jsx
Normal file
150
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
|
const [user, setUser] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prüfe beim Start, ob bereits ein Login-Token vorhanden ist
|
||||||
|
const storedAuth = localStorage.getItem('auth')
|
||||||
|
if (storedAuth) {
|
||||||
|
try {
|
||||||
|
const authData = JSON.parse(storedAuth)
|
||||||
|
if (authData.user && authData.credentials) {
|
||||||
|
setUser(authData.user)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Auth-Daten:', err)
|
||||||
|
localStorage.removeItem('auth')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = async (username, password) => {
|
||||||
|
try {
|
||||||
|
// Erstelle Basic Auth Header
|
||||||
|
const credentials = btoa(`${username}:${password}`)
|
||||||
|
|
||||||
|
console.log('Sende Login-Request:', { username, hasPassword: !!password })
|
||||||
|
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${credentials}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Login-Response Status:', response.status)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Login erfolgreich:', data)
|
||||||
|
const authData = {
|
||||||
|
user: data.user,
|
||||||
|
credentials: credentials
|
||||||
|
}
|
||||||
|
localStorage.setItem('auth', JSON.stringify(authData))
|
||||||
|
setUser(data.user)
|
||||||
|
setIsAuthenticated(true)
|
||||||
|
return { success: true, user: data.user }
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Login-Fehler Response:', errorText)
|
||||||
|
let errorData
|
||||||
|
try {
|
||||||
|
errorData = JSON.parse(errorText)
|
||||||
|
} catch {
|
||||||
|
errorData = { error: errorText || 'Ungültige Anmeldedaten' }
|
||||||
|
}
|
||||||
|
return { success: false, error: errorData.error || 'Ungültige Anmeldedaten' }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login-Fehler (Exception):', err)
|
||||||
|
return { success: false, error: 'Fehler bei der Anmeldung: ' + err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('auth')
|
||||||
|
setUser(null)
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAuthHeader = () => {
|
||||||
|
const storedAuth = localStorage.getItem('auth')
|
||||||
|
if (storedAuth) {
|
||||||
|
try {
|
||||||
|
const authData = JSON.parse(storedAuth)
|
||||||
|
if (authData.credentials) {
|
||||||
|
return `Basic ${authData.credentials}`
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Laden der Auth-Daten:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated fetch wrapper
|
||||||
|
const authFetch = async (url, options = {}) => {
|
||||||
|
const authHeader = getAuthHeader()
|
||||||
|
|
||||||
|
// Wenn Content-Type bereits gesetzt ist (z.B. für multipart/form-data), nicht überschreiben
|
||||||
|
const defaultHeaders = {}
|
||||||
|
// Nur Content-Type setzen, wenn es nicht FormData ist (FormData setzt Content-Type automatisch)
|
||||||
|
if (!(options.body instanceof FormData)) {
|
||||||
|
if (!options.headers || !options.headers['Content-Type']) {
|
||||||
|
defaultHeaders['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!options.headers || !options.headers['Accept']) {
|
||||||
|
defaultHeaders['Accept'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
...defaultHeaders,
|
||||||
|
...options.headers,
|
||||||
|
...(authHeader && { 'Authorization': authHeader }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wenn 401 Unauthorized, logout
|
||||||
|
if (response.status === 401) {
|
||||||
|
logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
isAuthenticated,
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
getAuthHeader,
|
||||||
|
authFetch,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth muss innerhalb eines AuthProvider verwendet werden')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
433
frontend/src/pages/AuditLogs.jsx
Normal file
433
frontend/src/pages/AuditLogs.jsx
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const AuditLogs = () => {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [logs, setLogs] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
action: '',
|
||||||
|
resourceType: '',
|
||||||
|
userId: '',
|
||||||
|
})
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
})
|
||||||
|
const [expandedLogs, setExpandedLogs] = useState(new Set())
|
||||||
|
|
||||||
|
const fetchLogs = async (silent = false) => {
|
||||||
|
try {
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(true)
|
||||||
|
}
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: pagination.limit.toString(),
|
||||||
|
offset: pagination.offset.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filters.action) params.append('action', filters.action)
|
||||||
|
if (filters.resourceType) params.append('resourceType', filters.resourceType)
|
||||||
|
if (filters.userId) params.append('userId', filters.userId)
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/audit-logs?${params.toString()}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der Audit-Logs')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Audit-Logs Response:', data)
|
||||||
|
console.log('Anzahl Logs:', data.logs?.length || 0)
|
||||||
|
setLogs(data.logs || [])
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
total: data.total || 0,
|
||||||
|
hasMore: data.hasMore || false,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching audit logs:', err)
|
||||||
|
if (!silent) {
|
||||||
|
setError('Fehler beim Laden der Audit-Logs')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs()
|
||||||
|
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||||
|
|
||||||
|
// Automatische Aktualisierung alle 5 Sekunden (silent, ohne Loading-State)
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchLogs(true) // Silent update - kein Loading-State
|
||||||
|
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters({ ...filters, [key]: value })
|
||||||
|
setPagination({ ...pagination, offset: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreviousPage = () => {
|
||||||
|
if (pagination.offset > 0) {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
offset: Math.max(0, pagination.offset - pagination.limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (pagination.hasMore) {
|
||||||
|
setPagination({
|
||||||
|
...pagination,
|
||||||
|
offset: pagination.offset + pagination.limit,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp) => {
|
||||||
|
try {
|
||||||
|
if (!timestamp) {
|
||||||
|
return { date: '-', time: '-' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ISO format (2025-11-20T16:45:22Z)
|
||||||
|
if (timestamp.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
|
||||||
|
const [datePart, timePart] = timestamp.split('T')
|
||||||
|
const timeOnly = timePart.split(/[Z+-]/)[0] // Entferne Zeitzone-Info (Z, +, -)
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: datePart, // 2025-11-20
|
||||||
|
time: timeOnly // 16:45:22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SQLite DATETIME format (YYYY-MM-DD HH:MM:SS)
|
||||||
|
if (timestamp.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||||
|
const [datePart, timePart] = timestamp.split(' ')
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: datePart, // 2025-11-20
|
||||||
|
time: timePart // 16:45:22
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback für andere Formate
|
||||||
|
return { date: timestamp, time: '-' }
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: Zeige den Timestamp direkt an
|
||||||
|
console.error('Fehler beim Formatieren des Timestamps:', error, timestamp)
|
||||||
|
return { date: timestamp || '-', time: '-' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionColor = (action) => {
|
||||||
|
const colors = {
|
||||||
|
CREATE: 'text-green-400',
|
||||||
|
UPDATE: 'text-blue-400',
|
||||||
|
DELETE: 'text-red-400',
|
||||||
|
UPLOAD: 'text-yellow-400',
|
||||||
|
SIGN: 'text-purple-400',
|
||||||
|
ENABLE: 'text-green-400',
|
||||||
|
DISABLE: 'text-orange-400',
|
||||||
|
}
|
||||||
|
return colors[action] || 'text-slate-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
CREATE: 'Erstellt',
|
||||||
|
UPDATE: 'Aktualisiert',
|
||||||
|
DELETE: 'Gelöscht',
|
||||||
|
UPLOAD: 'Hochgeladen',
|
||||||
|
SIGN: 'Signiert',
|
||||||
|
ENABLE: 'Aktiviert',
|
||||||
|
DISABLE: 'Deaktiviert',
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceTypeLabels = {
|
||||||
|
user: 'Benutzer',
|
||||||
|
space: 'Space',
|
||||||
|
fqdn: 'FQDN',
|
||||||
|
csr: 'CSR',
|
||||||
|
provider: 'Provider',
|
||||||
|
certificate: 'Zertifikat',
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLogExpansion = (logId) => {
|
||||||
|
setExpandedLogs(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(logId)) {
|
||||||
|
newSet.delete(logId)
|
||||||
|
} else {
|
||||||
|
newSet.add(logId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDetails = (details) => {
|
||||||
|
if (!details) return '-'
|
||||||
|
try {
|
||||||
|
const parsed = typeof details === 'string' ? JSON.parse(details) : details
|
||||||
|
return JSON.stringify(parsed, null, 2)
|
||||||
|
} catch {
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
||||||
|
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||||
|
<span>Live-Aktualisierung aktiv</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Aktion
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.action}
|
||||||
|
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
<option value="CREATE">Erstellt</option>
|
||||||
|
<option value="UPDATE">Aktualisiert</option>
|
||||||
|
<option value="DELETE">Gelöscht</option>
|
||||||
|
<option value="UPLOAD">Hochgeladen</option>
|
||||||
|
<option value="SIGN">Signiert</option>
|
||||||
|
<option value="ENABLE">Aktiviert</option>
|
||||||
|
<option value="DISABLE">Deaktiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Ressourcentyp
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.resourceType}
|
||||||
|
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="user">Benutzer</option>
|
||||||
|
<option value="space">Space</option>
|
||||||
|
<option value="fqdn">FQDN</option>
|
||||||
|
<option value="csr">CSR</option>
|
||||||
|
<option value="provider">Provider</option>
|
||||||
|
<option value="certificate">Zertifikat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Benutzer-ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||||
|
placeholder="Benutzer-ID filtern"
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/20 border border-red-500/50 rounded-lg p-4 mb-6 text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-8 text-center">
|
||||||
|
<svg className="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p className="text-slate-300">Lade Audit-Logs...</p>
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-8 text-center">
|
||||||
|
<p className="text-slate-300">Keine Audit-Logs gefunden</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-700/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider w-12">
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Zeitstempel
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Benutzer
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Aktion
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Ressource
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||||
|
IP-Adresse
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
|
{logs.map((log) => {
|
||||||
|
const isExpanded = expandedLogs.has(log.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={log.id} className="hover:bg-slate-700/30 transition-colors cursor-pointer" onClick={() => toggleLogExpansion(log.id)}>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-400">
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-300">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{formatTimestamp(log.timestamp).date} // {formatTimestamp(log.timestamp).time || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-300">
|
||||||
|
{log.username || log.userId || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm">
|
||||||
|
<span className={`font-semibold ${getActionColor(log.action)}`}>
|
||||||
|
{actionLabels[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-300">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{resourceTypeLabels[log.resourceType] || log.resourceType}
|
||||||
|
</span>
|
||||||
|
{log.resourceId && (
|
||||||
|
<span className="ml-2 text-xs text-slate-500">
|
||||||
|
({log.resourceId.substring(0, 8)}...)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-300 max-w-md truncate">
|
||||||
|
{log.details ? (
|
||||||
|
<span className="truncate block">
|
||||||
|
{typeof log.details === 'string' && log.details.length > 50
|
||||||
|
? log.details.substring(0, 50) + '...'
|
||||||
|
: log.details}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-400">
|
||||||
|
{log.ipAddress || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr key={`${log.id}-details`} className="bg-slate-800/50">
|
||||||
|
<td colSpan="7" className="px-4 py-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">Vollständige Details</h4>
|
||||||
|
<pre className="bg-slate-900/50 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-300 overflow-x-auto">
|
||||||
|
{formatDetails(log.details)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
{log.userAgent && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">User-Agent</h4>
|
||||||
|
<p className="text-xs text-slate-300 break-all">{log.userAgent}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.resourceId && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">Ressourcen-ID</h4>
|
||||||
|
<p className="text-xs text-slate-300 font-mono">{log.resourceId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.userId && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">Benutzer-ID</h4>
|
||||||
|
<p className="text-xs text-slate-300 font-mono">{log.userId}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-slate-300">
|
||||||
|
Zeige {pagination.offset + 1} - {Math.min(pagination.offset + pagination.limit, pagination.total)} von {pagination.total} Einträgen
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
disabled={pagination.offset === 0}
|
||||||
|
className="px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={!pagination.hasMore}
|
||||||
|
className="px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuditLogs
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import ProvidersSection from '../components/ProvidersSection'
|
import ProvidersSection from '../components/ProvidersSection'
|
||||||
|
|
||||||
const Home = () => {
|
const Home = () => {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [data, setData] = useState(null)
|
const [data, setData] = useState(null)
|
||||||
const [stats, setStats] = useState(null)
|
const [stats, setStats] = useState(null)
|
||||||
const [loadingStats, setLoadingStats] = useState(true)
|
const [loadingStats, setLoadingStats] = useState(true)
|
||||||
@@ -15,7 +17,7 @@ const Home = () => {
|
|||||||
if (isInitial) {
|
if (isInitial) {
|
||||||
setLoadingStats(true)
|
setLoadingStats(true)
|
||||||
}
|
}
|
||||||
const response = await fetch('/api/stats')
|
const response = await authFetch('/api/stats')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,7 @@ const Home = () => {
|
|||||||
// Tab is visible, resume polling
|
// Tab is visible, resume polling
|
||||||
if (!intervalRef.current && isMountedRef.current) {
|
if (!intervalRef.current && isMountedRef.current) {
|
||||||
// Fetch immediately when tab becomes visible
|
// Fetch immediately when tab becomes visible
|
||||||
fetch('/api/stats')
|
authFetch('/api/stats')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(statsData => {
|
.then(statsData => {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
@@ -84,7 +86,7 @@ const Home = () => {
|
|||||||
// Resume polling
|
// Resume polling
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
fetch('/api/stats')
|
authFetch('/api/stats')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(statsData => {
|
.then(statsData => {
|
||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
@@ -112,7 +114,7 @@ const Home = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [authFetch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true
|
isMountedRef.current = true
|
||||||
@@ -123,7 +125,7 @@ const Home = () => {
|
|||||||
if (isInitial) {
|
if (isInitial) {
|
||||||
setLoadingStats(true)
|
setLoadingStats(true)
|
||||||
}
|
}
|
||||||
const response = await fetch('/api/stats')
|
const response = await authFetch('/api/stats')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
@@ -176,7 +178,7 @@ const Home = () => {
|
|||||||
intervalRef.current = null
|
intervalRef.current = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []) // Empty dependency array - only run on mount
|
}, [authFetch]) // Include authFetch in dependencies
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||||
|
|||||||
168
frontend/src/pages/Login.jsx
Normal file
168
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { login, isAuthenticated } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Wenn bereits eingeloggt, weiterleiten
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate])
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
setError('Bitte geben Sie Benutzername und Passwort ein')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Login-Versuch:', { username })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await login(username, password)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
console.error('Login fehlgeschlagen:', result.error)
|
||||||
|
setError(result.error || 'Ungültige Anmeldedaten')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login-Fehler:', err)
|
||||||
|
setLoading(false)
|
||||||
|
setError('Fehler bei der Anmeldung. Bitte versuchen Sie es erneut.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-slate-900 to-slate-800 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo/Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl mb-4 shadow-lg">
|
||||||
|
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Certigo Addon</h1>
|
||||||
|
<p className="text-slate-400">Melden Sie sich an, um fortzufahren</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Card */}
|
||||||
|
<div className="bg-slate-800/90 backdrop-blur-sm rounded-2xl shadow-2xl border border-slate-700/50 p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Username Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-3 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Benutzername eingeben"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="block w-full pl-10 pr-3 py-3 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
|
placeholder="Passwort eingeben"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm text-red-300">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 px-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Wird angemeldet...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Anmelden'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Default Credentials Hint */}
|
||||||
|
<div className="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||||
|
<p className="text-xs text-blue-300 text-center">
|
||||||
|
<span className="font-semibold">Standard-Anmeldedaten:</span><br />
|
||||||
|
Benutzername: <code className="bg-slate-700/50 px-1 py-0.5 rounded">admin</code><br />
|
||||||
|
Passwort: <code className="bg-slate-700/50 px-1 py-0.5 rounded">admin</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-slate-500 text-sm mt-6">
|
||||||
|
© {new Date().getFullYear()} Certigo Addon. Alle Rechte vorbehalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
||||||
|
|
||||||
669
frontend/src/pages/Profile.jsx
Normal file
669
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
const { authFetch, user } = useAuth()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showSuccessAnimation, setShowSuccessAnimation] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||||
|
const [showCropModal, setShowCropModal] = useState(false)
|
||||||
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
const [cropImage, setCropImage] = useState(null)
|
||||||
|
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 })
|
||||||
|
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, size: 200 })
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
oldPassword: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({
|
||||||
|
username: user.username || '',
|
||||||
|
email: user.email || '',
|
||||||
|
oldPassword: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
// Lade Profilbild
|
||||||
|
loadAvatar()
|
||||||
|
}
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const loadAvatar = async () => {
|
||||||
|
if (user?.id) {
|
||||||
|
// Versuche Profilbild zu laden, mit Timestamp für Cache-Busting
|
||||||
|
const url = `/api/users/${user.id}/avatar?t=${Date.now()}`
|
||||||
|
try {
|
||||||
|
const response = await authFetch(url)
|
||||||
|
if (response.ok) {
|
||||||
|
setAvatarUrl(url)
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setAvatarUrl(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setAvatarUrl(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Validiere Dateityp
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
setError('Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere Dateigröße (max 10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
setError('Datei ist zu groß. Maximale Größe: 10MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedFile(file)
|
||||||
|
|
||||||
|
// Lade Bild für Crop-Modal
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setCropImage(e.target.result)
|
||||||
|
setShowCropModal(true)
|
||||||
|
// Setze initiale Crop-Position (zentriert)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const minSize = Math.min(img.width, img.height)
|
||||||
|
const cropSize = Math.min(minSize * 0.8, 400)
|
||||||
|
setImageDimensions({ width: img.width, height: img.height })
|
||||||
|
setCropPosition({
|
||||||
|
x: (img.width - cropSize) / 2,
|
||||||
|
y: (img.height - cropSize) / 2,
|
||||||
|
size: cropSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
img.src = e.target.result
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropDragStart = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(true)
|
||||||
|
const img = document.getElementById('crop-image')
|
||||||
|
if (!img) return
|
||||||
|
|
||||||
|
const rect = img.getBoundingClientRect()
|
||||||
|
const scaleX = imageDimensions.width / rect.width
|
||||||
|
const scaleY = imageDimensions.height / rect.height
|
||||||
|
|
||||||
|
setDragStart({
|
||||||
|
x: e.clientX - (cropPosition.x / scaleX + rect.left),
|
||||||
|
y: e.clientY - (cropPosition.y / scaleY + rect.top)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropDrag = (e) => {
|
||||||
|
if (!isDragging) return
|
||||||
|
|
||||||
|
const img = document.getElementById('crop-image')
|
||||||
|
if (!img) return
|
||||||
|
|
||||||
|
const rect = img.getBoundingClientRect()
|
||||||
|
const scaleX = imageDimensions.width / rect.width
|
||||||
|
const scaleY = imageDimensions.height / rect.height
|
||||||
|
|
||||||
|
const newX = (e.clientX - rect.left - dragStart.x) * scaleX
|
||||||
|
const newY = (e.clientY - rect.top - dragStart.y) * scaleY
|
||||||
|
|
||||||
|
// Begrenze auf Bildgrenzen
|
||||||
|
const maxX = imageDimensions.width - cropPosition.size
|
||||||
|
const maxY = imageDimensions.height - cropPosition.size
|
||||||
|
|
||||||
|
setCropPosition(prev => ({
|
||||||
|
...prev,
|
||||||
|
x: Math.max(0, Math.min(maxX, newX)),
|
||||||
|
y: Math.max(0, Math.min(maxY, newY))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropDragEnd = () => {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropResize = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsResizing(true)
|
||||||
|
|
||||||
|
const img = document.getElementById('crop-image')
|
||||||
|
if (!img) return
|
||||||
|
|
||||||
|
const rect = img.getBoundingClientRect()
|
||||||
|
const scale = Math.min(imageDimensions.width / rect.width, imageDimensions.height / rect.height)
|
||||||
|
const startY = e.clientY
|
||||||
|
const startSize = cropPosition.size
|
||||||
|
const startX = cropPosition.x
|
||||||
|
const startYPos = cropPosition.y
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent) => {
|
||||||
|
const deltaY = (moveEvent.clientY - startY) * scale
|
||||||
|
const newSize = Math.max(50, Math.min(
|
||||||
|
Math.min(imageDimensions.width, imageDimensions.height),
|
||||||
|
startSize - deltaY
|
||||||
|
))
|
||||||
|
|
||||||
|
// Zentriere Crop-Bereich bei Größenänderung
|
||||||
|
const maxX = imageDimensions.width - newSize
|
||||||
|
const maxY = imageDimensions.height - newSize
|
||||||
|
|
||||||
|
setCropPosition({
|
||||||
|
x: Math.max(0, Math.min(maxX, startX + (startSize - newSize) / 2)),
|
||||||
|
y: Math.max(0, Math.min(maxY, startYPos + (startSize - newSize) / 2)),
|
||||||
|
size: newSize
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropImageToCircle = async () => {
|
||||||
|
if (!cropImage) return null
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const size = cropPosition.size
|
||||||
|
|
||||||
|
canvas.width = size
|
||||||
|
canvas.height = size
|
||||||
|
|
||||||
|
// Erstelle kreisförmigen Clip-Pfad
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI)
|
||||||
|
ctx.clip()
|
||||||
|
|
||||||
|
// Zeichne zugeschnittenes Bild
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
cropPosition.x, cropPosition.y, cropPosition.size, cropPosition.size,
|
||||||
|
0, 0, size, size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Konvertiere zu Blob
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
resolve(blob)
|
||||||
|
}, 'image/png', 0.95)
|
||||||
|
}
|
||||||
|
img.src = cropImage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCropConfirm = async () => {
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const croppedBlob = await cropImageToCircle()
|
||||||
|
if (!croppedBlob) {
|
||||||
|
setError('Fehler beim Zuschneiden des Bildes')
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle File aus Blob
|
||||||
|
const file = new File([croppedBlob], selectedFile.name, { type: 'image/png' })
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('avatar', file)
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/users/${user.id}/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSuccess('Profilbild erfolgreich hochgeladen')
|
||||||
|
setShowCropModal(false)
|
||||||
|
setSelectedFile(null)
|
||||||
|
setCropImage(null)
|
||||||
|
// Lade Profilbild neu
|
||||||
|
loadAvatar()
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
setError(errorData.error || 'Fehler beim Hochladen des Profilbilds')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Hochladen des Profilbilds')
|
||||||
|
console.error('Error uploading avatar:', err)
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
setShowSuccessAnimation(false)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Validierung: Passwort-Bestätigung muss übereinstimmen
|
||||||
|
if (formData.password && formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Die Passwörter stimmen nicht überein')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung: Wenn Passwort geändert wird, muss altes Passwort vorhanden sein
|
||||||
|
if (formData.password && !formData.oldPassword) {
|
||||||
|
setError('Bitte geben Sie das alte Passwort ein, um das Passwort zu ändern')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...(formData.username && { username: formData.username }),
|
||||||
|
...(formData.email && { email: formData.email }),
|
||||||
|
...(formData.password && {
|
||||||
|
password: formData.password,
|
||||||
|
oldPassword: formData.oldPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(`/api/users/${user.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowSuccessAnimation(true)
|
||||||
|
// Warte kurz, damit Animation sichtbar ist
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSuccessAnimation(false)
|
||||||
|
// Aktualisiere User-Daten im AuthContext
|
||||||
|
window.location.reload()
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
setError(errorData.error || 'Fehler beim Aktualisieren des Profils')
|
||||||
|
setShowSuccessAnimation(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Aktualisieren des Profils')
|
||||||
|
setShowSuccessAnimation(false)
|
||||||
|
console.error('Error updating profile:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
})
|
||||||
|
// Clear success/error messages when user starts typing
|
||||||
|
if (success) setSuccess('')
|
||||||
|
if (error) setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
||||||
|
<p className="text-slate-300">Lade Profil...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-2">Mein Profil</h1>
|
||||||
|
<p className="text-lg text-slate-200">
|
||||||
|
Verwalten Sie Ihre persönlichen Daten und Einstellungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||||
|
{/* Profilbild */}
|
||||||
|
<div className="flex items-center gap-6 mb-8 pb-8 border-b border-slate-700/50">
|
||||||
|
<div className="relative">
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt="Profilbild"
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-2 border-slate-600"
|
||||||
|
onError={() => {
|
||||||
|
// Wenn Bild nicht geladen werden kann, setze avatarUrl auf null
|
||||||
|
setAvatarUrl(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 rounded-full bg-slate-700/50 border-2 border-slate-600 flex items-center justify-center">
|
||||||
|
<span className="text-4xl text-slate-400">👤</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white text-sm transition-colors cursor-pointer shadow-lg"
|
||||||
|
title="Profilbild ändern"
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? (
|
||||||
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
'📷'
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-1">{user.username}</h2>
|
||||||
|
<p className="text-slate-300">{user.email}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
{avatarUrl ? 'Klicken Sie auf das Kamera-Icon, um Ihr Profilbild zu ändern' : 'Klicken Sie auf das Kamera-Icon, um ein Profilbild hochzuladen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profil bearbeiten Formular */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie Ihren Benutzernamen ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie Ihre E-Mail-Adresse ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-slate-700/50">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4">Passwort ändern</h3>
|
||||||
|
<p className="text-sm text-slate-400 mb-4">
|
||||||
|
Lassen Sie die Felder leer, wenn Sie Ihr Passwort nicht ändern möchten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="oldPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Altes Passwort {formData.password && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="oldPassword"
|
||||||
|
name="oldPassword"
|
||||||
|
value={formData.oldPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!!formData.password}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie Ihr aktuelles Passwort ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie ein neues Passwort ein"
|
||||||
|
/>
|
||||||
|
{/* Passwortrichtlinie - nur anzeigen wenn Passwort eingegeben wird */}
|
||||||
|
{formData.password && (
|
||||||
|
<div className="mt-2 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-slate-300 mb-2">Passwortrichtlinie:</p>
|
||||||
|
<ul className="text-xs text-slate-400 space-y-1">
|
||||||
|
<li className={`flex items-center gap-2 ${formData.password.length >= 8 ? 'text-green-400' : ''}`}>
|
||||||
|
{formData.password.length >= 8 ? '✓' : '○'} Mindestens 8 Zeichen
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[A-Z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[A-Z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Großbuchstabe
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[a-z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[a-z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Kleinbuchstabe
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[0-9]/.test(formData.password) ? '✓' : '○'} Mindestens eine Zahl
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[^A-Za-z0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[^A-Za-z0-9]/.test(formData.password) ? '✓' : '○'} Mindestens ein Sonderzeichen
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Neues Passwort bestätigen {formData.password && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!!formData.password}
|
||||||
|
className={`w-full px-4 py-2 bg-slate-700/50 border rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||||
|
? 'border-red-500'
|
||||||
|
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||||
|
? 'border-green-500'
|
||||||
|
: 'border-slate-600'
|
||||||
|
}`}
|
||||||
|
placeholder="Bestätigen Sie das neue Passwort"
|
||||||
|
/>
|
||||||
|
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Die Passwörter stimmen nicht überein</p>
|
||||||
|
)}
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && formData.password && (
|
||||||
|
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || showSuccessAnimation}
|
||||||
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Wird gespeichert...
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!loading && 'Profil aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Animation Popup */}
|
||||||
|
{showSuccessAnimation && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||||
|
<div className="bg-slate-800 rounded-lg shadow-2xl p-8 flex flex-col items-center">
|
||||||
|
<div className="relative w-20 h-20 mb-4">
|
||||||
|
{/* Ping Animation */}
|
||||||
|
<div className="absolute inset-0 bg-green-500 rounded-full animate-ping opacity-75"></div>
|
||||||
|
{/* Checkmark Circle */}
|
||||||
|
<div className="relative w-20 h-20 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold text-white">Profil erfolgreich aktualisiert</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Crop Modal */}
|
||||||
|
{showCropModal && cropImage && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-slate-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-4">Profilbild zuschneiden</h2>
|
||||||
|
<p className="text-slate-300 mb-6">
|
||||||
|
Verschieben Sie den Kreis, um den gewünschten Bereich auszuwählen. Ziehen Sie an den Ecken, um die Größe zu ändern.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative inline-block"
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (isDragging) handleCropDrag(e)
|
||||||
|
if (isResizing) return
|
||||||
|
}}
|
||||||
|
onMouseUp={handleCropDragEnd}
|
||||||
|
onMouseLeave={handleCropDragEnd}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
id="crop-image"
|
||||||
|
src={cropImage}
|
||||||
|
alt="Zu schneidendes Bild"
|
||||||
|
className="max-w-full h-auto block"
|
||||||
|
style={{ maxHeight: '70vh' }}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Crop-Bereich (Kreis) */}
|
||||||
|
{imageDimensions.width > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute border-4 border-blue-500 rounded-full cursor-move"
|
||||||
|
style={{
|
||||||
|
left: `${(cropPosition.x / imageDimensions.width) * 100}%`,
|
||||||
|
top: `${(cropPosition.y / imageDimensions.height) * 100}%`,
|
||||||
|
width: `${(cropPosition.size / imageDimensions.width) * 100}%`,
|
||||||
|
height: `${(cropPosition.size / imageDimensions.height) * 100}%`,
|
||||||
|
aspectRatio: '1 / 1',
|
||||||
|
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
||||||
|
pointerEvents: isResizing ? 'none' : 'auto'
|
||||||
|
}}
|
||||||
|
onMouseDown={handleCropDragStart}
|
||||||
|
>
|
||||||
|
{/* Resize-Handles an den Ecken */}
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||||
|
onMouseDown={handleCropResize}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -top-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||||
|
onMouseDown={handleCropResize}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||||
|
onMouseDown={handleCropResize}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute -bottom-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||||
|
onMouseDown={handleCropResize}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleCropConfirm}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{uploadingAvatar ? 'Wird hochgeladen...' : 'Zuschneiden und hochladen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowCropModal(false)
|
||||||
|
setSelectedFile(null)
|
||||||
|
setCropImage(null)
|
||||||
|
}}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const SpaceDetail = () => {
|
const SpaceDetail = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [space, setSpace] = useState(null)
|
const [space, setSpace] = useState(null)
|
||||||
const [fqdns, setFqdns] = useState([])
|
const [fqdns, setFqdns] = useState([])
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
@@ -47,7 +49,7 @@ const SpaceDetail = () => {
|
|||||||
const fetchSpace = async () => {
|
const fetchSpace = async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingSpace(true)
|
setLoadingSpace(true)
|
||||||
const response = await fetch('/api/spaces')
|
const response = await authFetch('/api/spaces')
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const spaces = await response.json()
|
const spaces = await response.json()
|
||||||
const foundSpace = spaces.find(s => s.id === id)
|
const foundSpace = spaces.find(s => s.id === id)
|
||||||
@@ -70,7 +72,7 @@ const SpaceDetail = () => {
|
|||||||
const fetchFqdns = async () => {
|
const fetchFqdns = async () => {
|
||||||
try {
|
try {
|
||||||
setFetchError('')
|
setFetchError('')
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns`)
|
const response = await authFetch(`/api/spaces/${id}/fqdns`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setFqdns(Array.isArray(data) ? data : [])
|
setFqdns(Array.isArray(data) ? data : [])
|
||||||
@@ -108,7 +110,7 @@ const SpaceDetail = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns`, {
|
const response = await authFetch(`/api/spaces/${id}/fqdns`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -165,7 +167,7 @@ const SpaceDetail = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,7 +218,7 @@ const SpaceDetail = () => {
|
|||||||
formData.append('fqdn', fqdn.fqdn)
|
formData.append('fqdn', fqdn.fqdn)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`, {
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
@@ -254,7 +256,7 @@ const SpaceDetail = () => {
|
|||||||
|
|
||||||
const fetchCSR = async (fqdn) => {
|
const fetchCSR = async (fqdn) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const csr = await response.json()
|
const csr = await response.json()
|
||||||
if (csr) {
|
if (csr) {
|
||||||
@@ -278,7 +280,7 @@ const SpaceDetail = () => {
|
|||||||
// Lade neuesten CSR und alle CSRs für History
|
// Lade neuesten CSR und alle CSRs für History
|
||||||
try {
|
try {
|
||||||
// Lade neuesten CSR
|
// Lade neuesten CSR
|
||||||
const latestResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
const latestResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||||
if (latestResponse.ok) {
|
if (latestResponse.ok) {
|
||||||
const csr = await latestResponse.json()
|
const csr = await latestResponse.json()
|
||||||
setCsrData(csr || null)
|
setCsrData(csr || null)
|
||||||
@@ -287,7 +289,7 @@ const SpaceDetail = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lade alle CSRs für History
|
// Lade alle CSRs für History
|
||||||
const historyResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||||
if (historyResponse.ok) {
|
if (historyResponse.ok) {
|
||||||
const history = await historyResponse.json()
|
const history = await historyResponse.json()
|
||||||
setCsrHistory(Array.isArray(history) ? history : [])
|
setCsrHistory(Array.isArray(history) ? history : [])
|
||||||
@@ -330,7 +332,7 @@ const SpaceDetail = () => {
|
|||||||
|
|
||||||
// Lade neuesten CSR
|
// Lade neuesten CSR
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const csr = await response.json()
|
const csr = await response.json()
|
||||||
setCsrData(csr)
|
setCsrData(csr)
|
||||||
@@ -341,7 +343,7 @@ const SpaceDetail = () => {
|
|||||||
|
|
||||||
// Lade Provider
|
// Lade Provider
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/providers')
|
const response = await authFetch('/api/providers')
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const providersData = await response.json()
|
const providersData = await response.json()
|
||||||
setProviders(providersData.filter(p => p.enabled))
|
setProviders(providersData.filter(p => p.enabled))
|
||||||
@@ -356,7 +358,7 @@ const SpaceDetail = () => {
|
|||||||
const handleTestProvider = async (providerId) => {
|
const handleTestProvider = async (providerId) => {
|
||||||
setProviderTestResult(null)
|
setProviderTestResult(null)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/providers/${providerId}/test`, {
|
const response = await authFetch(`/api/providers/${providerId}/test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
@@ -375,7 +377,7 @@ const SpaceDetail = () => {
|
|||||||
setSignResult(null)
|
setSignResult(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/csr/sign`, {
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/csr/sign`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -389,7 +391,7 @@ const SpaceDetail = () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Lade Zertifikate automatisch neu, um das neue Zertifikat anzuzeigen
|
// Lade Zertifikate automatisch neu, um das neue Zertifikat anzuzeigen
|
||||||
try {
|
try {
|
||||||
const certResponse = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates`)
|
const certResponse = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates`)
|
||||||
if (certResponse.ok) {
|
if (certResponse.ok) {
|
||||||
const certs = await certResponse.json()
|
const certs = await certResponse.json()
|
||||||
setCertificates(certs)
|
setCertificates(certs)
|
||||||
@@ -420,7 +422,7 @@ const SpaceDetail = () => {
|
|||||||
setCertificates([])
|
setCertificates([])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const certs = await response.json()
|
const certs = await response.json()
|
||||||
setCertificates(certs)
|
setCertificates(certs)
|
||||||
@@ -438,7 +440,7 @@ const SpaceDetail = () => {
|
|||||||
const handleRefreshCertificate = async (cert) => {
|
const handleRefreshCertificate = async (cert) => {
|
||||||
setRefreshingCertificate(cert.id)
|
setRefreshingCertificate(cert.id)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, {
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -766,7 +768,7 @@ const SpaceDetail = () => {
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
// Lade CSR-History wenn Bereich erweitert wird
|
// Lade CSR-History wenn Bereich erweitert wird
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const history = await response.json()
|
const history = await response.json()
|
||||||
// Speichere History mit FQDN-ID als Key
|
// Speichere History mit FQDN-ID als Key
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
const Spaces = () => {
|
const Spaces = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { authFetch } = useAuth()
|
||||||
const [spaces, setSpaces] = useState([])
|
const [spaces, setSpaces] = useState([])
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -26,7 +28,7 @@ const Spaces = () => {
|
|||||||
const fetchSpaces = async () => {
|
const fetchSpaces = async () => {
|
||||||
try {
|
try {
|
||||||
setFetchError('')
|
setFetchError('')
|
||||||
const response = await fetch('/api/spaces')
|
const response = await authFetch('/api/spaces')
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Stelle sicher, dass data ein Array ist
|
// Stelle sicher, dass data ein Array ist
|
||||||
@@ -57,7 +59,7 @@ const Spaces = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/spaces', {
|
const response = await authFetch('/api/spaces', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -93,7 +95,7 @@ const Spaces = () => {
|
|||||||
// Hole die Anzahl der FQDNs für diesen Space
|
// Hole die Anzahl der FQDNs für diesen Space
|
||||||
let count = 0
|
let count = 0
|
||||||
try {
|
try {
|
||||||
const countResponse = await fetch(`/api/spaces/${space.id}/fqdns/count`)
|
const countResponse = await authFetch(`/api/spaces/${space.id}/fqdns/count`)
|
||||||
if (countResponse.ok) {
|
if (countResponse.ok) {
|
||||||
const countData = await countResponse.json()
|
const countData = await countResponse.json()
|
||||||
count = countData.count || 0
|
count = countData.count || 0
|
||||||
@@ -127,7 +129,7 @@ const Spaces = () => {
|
|||||||
? `/api/spaces/${spaceToDelete.id}?deleteFqdns=true`
|
? `/api/spaces/${spaceToDelete.id}?deleteFqdns=true`
|
||||||
: `/api/spaces/${spaceToDelete.id}`
|
: `/api/spaces/${spaceToDelete.id}`
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await authFetch(url, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,7 +268,7 @@ const Spaces = () => {
|
|||||||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||||
<p className="text-red-300 mb-2">{fetchError}</p>
|
<p className="text-red-300 mb-2">{fetchError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchSpaces}
|
onClick={() => fetchSpaces()}
|
||||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all duration-200"
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
|
|||||||
385
frontend/src/pages/Users.jsx
Normal file
385
frontend/src/pages/Users.jsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
const Users = () => {
|
||||||
|
const { authFetch } = useAuth()
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
oldPassword: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
const response = await authFetch('/api/users')
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setUsers(Array.isArray(data) ? data : [])
|
||||||
|
} else {
|
||||||
|
setError('Fehler beim Abrufen der Benutzer')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Abrufen der Benutzer')
|
||||||
|
console.error('Error fetching users:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Validierung: Passwort-Bestätigung muss übereinstimmen
|
||||||
|
if (formData.password && formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Die Passwörter stimmen nicht überein')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung: Wenn Passwort geändert wird, muss altes Passwort vorhanden sein
|
||||||
|
if (editingUser && formData.password && !formData.oldPassword) {
|
||||||
|
setError('Bitte geben Sie das alte Passwort ein, um das Passwort zu ändern')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = editingUser
|
||||||
|
? `/api/users/${editingUser.id}`
|
||||||
|
: '/api/users'
|
||||||
|
const method = editingUser ? 'PUT' : 'POST'
|
||||||
|
|
||||||
|
const body = editingUser
|
||||||
|
? {
|
||||||
|
...(formData.username && { username: formData.username }),
|
||||||
|
...(formData.email && { email: formData.email }),
|
||||||
|
...(formData.password && {
|
||||||
|
password: formData.password,
|
||||||
|
oldPassword: formData.oldPassword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
username: formData.username,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await authFetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchUsers()
|
||||||
|
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingUser(null)
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
setError(errorData.error || 'Fehler beim Speichern des Benutzers')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Speichern des Benutzers')
|
||||||
|
console.error('Error saving user:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (user) => {
|
||||||
|
setEditingUser(user)
|
||||||
|
setFormData({
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
oldPassword: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (userId) => {
|
||||||
|
if (!window.confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchUsers()
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json()
|
||||||
|
alert(errorData.error || 'Fehler beim Löschen des Benutzers')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Löschen des Benutzers')
|
||||||
|
console.error('Error deleting user:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-2">Benutzerverwaltung</h1>
|
||||||
|
<p className="text-lg text-slate-200">
|
||||||
|
Verwalten Sie lokale Benutzer und deren Zugangsdaten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(!showForm)
|
||||||
|
setEditingUser(null)
|
||||||
|
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||||
|
}}
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
|
||||||
|
>
|
||||||
|
{showForm ? 'Abbrechen' : '+ Neuer Benutzer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit User Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||||
|
{editingUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer erstellen'}
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Benutzername {!editingUser && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!editingUser}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie einen Benutzernamen ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
E-Mail {!editingUser && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!editingUser}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie eine E-Mail-Adresse ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editingUser && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="oldPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Altes Passwort {formData.password && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="oldPassword"
|
||||||
|
name="oldPassword"
|
||||||
|
value={formData.oldPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!!formData.password}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="Geben Sie Ihr aktuelles Passwort ein"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
{editingUser ? 'Neues Passwort' : 'Passwort'} {!editingUser && '*'}
|
||||||
|
{editingUser && <span className="text-xs text-slate-400 ml-2">(leer lassen, um nicht zu ändern)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!editingUser}
|
||||||
|
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder={editingUser ? "Geben Sie ein neues Passwort ein" : "Geben Sie ein Passwort ein"}
|
||||||
|
/>
|
||||||
|
{/* Passwortrichtlinie - nur anzeigen wenn Passwort eingegeben wird */}
|
||||||
|
{(formData.password || !editingUser) && (
|
||||||
|
<div className="mt-2 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
|
||||||
|
<p className="text-xs font-semibold text-slate-300 mb-2">Passwortrichtlinie:</p>
|
||||||
|
<ul className="text-xs text-slate-400 space-y-1">
|
||||||
|
<li className={`flex items-center gap-2 ${formData.password.length >= 8 ? 'text-green-400' : ''}`}>
|
||||||
|
{formData.password.length >= 8 ? '✓' : '○'} Mindestens 8 Zeichen
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[A-Z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[A-Z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Großbuchstabe
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[a-z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[a-z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Kleinbuchstabe
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[0-9]/.test(formData.password) ? '✓' : '○'} Mindestens eine Zahl
|
||||||
|
</li>
|
||||||
|
<li className={`flex items-center gap-2 ${/[^A-Za-z0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||||
|
{/[^A-Za-z0-9]/.test(formData.password) ? '✓' : '○'} Mindestens ein Sonderzeichen
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||||
|
{editingUser ? 'Neues Passwort bestätigen' : 'Passwort bestätigen'} {!editingUser && '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!editingUser || !!formData.password}
|
||||||
|
className={`w-full px-4 py-2 bg-slate-700/50 border rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||||
|
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||||
|
? 'border-red-500'
|
||||||
|
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||||
|
? 'border-green-500'
|
||||||
|
: 'border-slate-600'
|
||||||
|
}`}
|
||||||
|
placeholder={editingUser ? "Bestätigen Sie das neue Passwort" : "Bestätigen Sie das Passwort"}
|
||||||
|
/>
|
||||||
|
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-400">Die Passwörter stimmen nicht überein</p>
|
||||||
|
)}
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && formData.password && (
|
||||||
|
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{loading ? 'Wird gespeichert...' : editingUser ? 'Aktualisieren' : 'Benutzer erstellen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false)
|
||||||
|
setEditingUser(null)
|
||||||
|
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users List */}
|
||||||
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||||
|
Benutzer
|
||||||
|
</h2>
|
||||||
|
{error && !showForm && (
|
||||||
|
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||||
|
<p className="text-red-300 mb-2">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchUsers}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="text-slate-300 text-center py-8">
|
||||||
|
Noch keine Benutzer vorhanden. Erstellen Sie Ihren ersten Benutzer!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50 hover:border-slate-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">
|
||||||
|
{user.username}
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-300 mb-2">{user.email}</p>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Erstellt: {user.createdAt ? new Date(user.createdAt).toLocaleString('de-DE') : 'Unbekannt'}
|
||||||
|
</p>
|
||||||
|
{user.id && (
|
||||||
|
<p className="text-xs text-slate-500 font-mono mt-1">
|
||||||
|
ID: {user.id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Users
|
||||||
|
|
||||||
Reference in New Issue
Block a user