push newest version

This commit is contained in:
2025-11-20 17:59:34 +01:00
parent c0e2df2430
commit 97ccd7bfbf
21 changed files with 3978 additions and 65 deletions

198
DB_COMMANDS.md Normal file
View 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
```

View File

@@ -1,9 +1,13 @@
module certigo-addon-backend
go 1.21
go 1.24.0
toolchain go1.24.10
require (
github.com/google/uuid v1.5.0
github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.18
)
require golang.org/x/crypto v0.45.0 // indirect

View File

@@ -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/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=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=

View 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
}

File diff suppressed because it is too large Load Diff

42
backend/scripts/README.md Normal file
View 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.

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -1,33 +1,92 @@
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 Footer from './components/Footer'
import Home from './pages/Home'
import Spaces from './pages/Spaces'
import SpaceDetail from './pages/SpaceDetail'
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)
return (
<Router>
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
<div className="flex flex-1 overflow-hidden">
<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">
<div className="flex-1">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/spaces" element={<Spaces />} />
<Route path="/spaces/:id" element={<SpaceDetail />} />
<Route path="/impressum" element={<Impressum />} />
</Routes>
</div>
<Footer />
</main>
</div>
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
<div className="flex flex-1 overflow-hidden">
<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">
<div className="flex-1">
<Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} />
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} />
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/settings/users" element={<ProtectedRoute><Users /></ProtectedRoute>} />
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} />
</Routes>
</div>
<Footer />
</main>
</div>
</div>
)
}
function App() {
return (
<Router>
<AuthProvider>
<AppContent />
</AuthProvider>
</Router>
)
}

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
const ProvidersSection = () => {
const { authFetch } = useAuth()
const [providers, setProviders] = useState([])
const [loading, setLoading] = useState(true)
const [showConfigModal, setShowConfigModal] = useState(false)
@@ -11,11 +13,11 @@ const ProvidersSection = () => {
useEffect(() => {
fetchProviders()
}, [])
}, [authFetch])
const fetchProviders = async () => {
try {
const response = await fetch('/api/providers')
const response = await authFetch('/api/providers')
if (response.ok) {
const data = await response.json()
// Definiere feste Reihenfolge der Provider
@@ -35,7 +37,7 @@ const ProvidersSection = () => {
const handleToggleProvider = async (providerId, currentEnabled) => {
try {
const response = await fetch(`/api/providers/${providerId}/enabled`, {
const response = await authFetch(`/api/providers/${providerId}/enabled`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
@@ -60,7 +62,7 @@ const ProvidersSection = () => {
// Lade aktuelle Konfiguration
try {
const response = await fetch(`/api/providers/${provider.id}`)
const response = await authFetch(`/api/providers/${provider.id}`)
if (response.ok) {
const data = await response.json()
// Initialisiere Config-Werte
@@ -109,7 +111,7 @@ const ProvidersSection = () => {
setTestResult(null)
try {
const response = await fetch(`/api/providers/${selectedProvider.id}/test`, {
const response = await authFetch(`/api/providers/${selectedProvider.id}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -134,7 +136,7 @@ const ProvidersSection = () => {
if (!selectedProvider) return
try {
const response = await fetch(`/api/providers/${selectedProvider.id}/config`, {
const response = await authFetch(`/api/providers/${selectedProvider.id}/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',

View File

@@ -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 location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
const [expandedMenus, setExpandedMenus] = useState({})
const menuItems = [
{ path: '/', label: 'Home', icon: '🏠' },
{ path: '/spaces', label: 'Spaces', icon: '📁' },
{ path: '/audit-logs', label: 'Audit Log', 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) => {
if (path === '/') {
return location.pathname === '/'
@@ -16,6 +34,27 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
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 (
<>
{/* Overlay for mobile */}
@@ -63,8 +102,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</div>
</button>
</div>
<nav className="px-2 py-4 overflow-hidden">
<ul className="space-y-2">
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
<ul className="space-y-2 flex-1">
{menuItems.map((item) => (
<li key={item.path}>
<Link
@@ -87,7 +126,104 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</Link>
</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>
{/* 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>
</aside>
</>

View 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
}

View 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

View File

@@ -1,7 +1,9 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext'
import ProvidersSection from '../components/ProvidersSection'
const Home = () => {
const { authFetch } = useAuth()
const [data, setData] = useState(null)
const [stats, setStats] = useState(null)
const [loadingStats, setLoadingStats] = useState(true)
@@ -15,7 +17,7 @@ const Home = () => {
if (isInitial) {
setLoadingStats(true)
}
const response = await fetch('/api/stats')
const response = await authFetch('/api/stats')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
@@ -62,7 +64,7 @@ const Home = () => {
// Tab is visible, resume polling
if (!intervalRef.current && isMountedRef.current) {
// Fetch immediately when tab becomes visible
fetch('/api/stats')
authFetch('/api/stats')
.then(res => res.json())
.then(statsData => {
if (isMountedRef.current) {
@@ -84,7 +86,7 @@ const Home = () => {
// Resume polling
intervalRef.current = setInterval(() => {
if (isMountedRef.current) {
fetch('/api/stats')
authFetch('/api/stats')
.then(res => res.json())
.then(statsData => {
if (isMountedRef.current) {
@@ -112,7 +114,7 @@ const Home = () => {
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [])
}, [authFetch])
useEffect(() => {
isMountedRef.current = true
@@ -123,7 +125,7 @@ const Home = () => {
if (isInitial) {
setLoadingStats(true)
}
const response = await fetch('/api/stats')
const response = await authFetch('/api/stats')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
@@ -176,7 +178,7 @@ const Home = () => {
intervalRef.current = null
}
}
}, []) // Empty dependency array - only run on mount
}, [authFetch]) // Include authFetch in dependencies
return (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">

View 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

View 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

View File

@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const SpaceDetail = () => {
const { id } = useParams()
const navigate = useNavigate()
const { authFetch } = useAuth()
const [space, setSpace] = useState(null)
const [fqdns, setFqdns] = useState([])
const [showForm, setShowForm] = useState(false)
@@ -47,7 +49,7 @@ const SpaceDetail = () => {
const fetchSpace = async () => {
try {
setLoadingSpace(true)
const response = await fetch('/api/spaces')
const response = await authFetch('/api/spaces')
if (response.ok) {
const spaces = await response.json()
const foundSpace = spaces.find(s => s.id === id)
@@ -70,7 +72,7 @@ const SpaceDetail = () => {
const fetchFqdns = async () => {
try {
setFetchError('')
const response = await fetch(`/api/spaces/${id}/fqdns`)
const response = await authFetch(`/api/spaces/${id}/fqdns`)
if (response.ok) {
const data = await response.json()
setFqdns(Array.isArray(data) ? data : [])
@@ -108,7 +110,7 @@ const SpaceDetail = () => {
}
try {
const response = await fetch(`/api/spaces/${id}/fqdns`, {
const response = await authFetch(`/api/spaces/${id}/fqdns`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -165,7 +167,7 @@ const SpaceDetail = () => {
}
try {
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
method: 'DELETE',
})
@@ -216,7 +218,7 @@ const SpaceDetail = () => {
formData.append('fqdn', fqdn.fqdn)
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',
body: formData,
})
@@ -254,7 +256,7 @@ const SpaceDetail = () => {
const fetchCSR = async (fqdn) => {
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) {
const csr = await response.json()
if (csr) {
@@ -278,7 +280,7 @@ const SpaceDetail = () => {
// Lade neuesten CSR und alle CSRs für History
try {
// 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) {
const csr = await latestResponse.json()
setCsrData(csr || null)
@@ -287,7 +289,7 @@ const SpaceDetail = () => {
}
// 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) {
const history = await historyResponse.json()
setCsrHistory(Array.isArray(history) ? history : [])
@@ -330,7 +332,7 @@ const SpaceDetail = () => {
// Lade neuesten CSR
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) {
const csr = await response.json()
setCsrData(csr)
@@ -341,7 +343,7 @@ const SpaceDetail = () => {
// Lade Provider
try {
const response = await fetch('/api/providers')
const response = await authFetch('/api/providers')
if (response.ok) {
const providersData = await response.json()
setProviders(providersData.filter(p => p.enabled))
@@ -356,7 +358,7 @@ const SpaceDetail = () => {
const handleTestProvider = async (providerId) => {
setProviderTestResult(null)
try {
const response = await fetch(`/api/providers/${providerId}/test`, {
const response = await authFetch(`/api/providers/${providerId}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
@@ -375,7 +377,7 @@ const SpaceDetail = () => {
setSignResult(null)
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -389,7 +391,7 @@ const SpaceDetail = () => {
if (result.success) {
// Lade Zertifikate automatisch neu, um das neue Zertifikat anzuzeigen
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) {
const certs = await certResponse.json()
setCertificates(certs)
@@ -420,7 +422,7 @@ const SpaceDetail = () => {
setCertificates([])
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) {
const certs = await response.json()
setCertificates(certs)
@@ -438,7 +440,7 @@ const SpaceDetail = () => {
const handleRefreshCertificate = async (cert) => {
setRefreshingCertificate(cert.id)
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'
})
if (response.ok) {
@@ -766,7 +768,7 @@ const SpaceDetail = () => {
if (!isOpen) {
// Lade CSR-History wenn Bereich erweitert wird
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) {
const history = await response.json()
// Speichere History mit FQDN-ID als Key

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const Spaces = () => {
const navigate = useNavigate()
const { authFetch } = useAuth()
const [spaces, setSpaces] = useState([])
const [showForm, setShowForm] = useState(false)
const [formData, setFormData] = useState({
@@ -26,7 +28,7 @@ const Spaces = () => {
const fetchSpaces = async () => {
try {
setFetchError('')
const response = await fetch('/api/spaces')
const response = await authFetch('/api/spaces')
if (response.ok) {
const data = await response.json()
// Stelle sicher, dass data ein Array ist
@@ -57,7 +59,7 @@ const Spaces = () => {
}
try {
const response = await fetch('/api/spaces', {
const response = await authFetch('/api/spaces', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -93,7 +95,7 @@ const Spaces = () => {
// Hole die Anzahl der FQDNs für diesen Space
let count = 0
try {
const countResponse = await fetch(`/api/spaces/${space.id}/fqdns/count`)
const countResponse = await authFetch(`/api/spaces/${space.id}/fqdns/count`)
if (countResponse.ok) {
const countData = await countResponse.json()
count = countData.count || 0
@@ -127,7 +129,7 @@ const Spaces = () => {
? `/api/spaces/${spaceToDelete.id}?deleteFqdns=true`
: `/api/spaces/${spaceToDelete.id}`
const response = await fetch(url, {
const response = await authFetch(url, {
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">
<p className="text-red-300 mb-2">{fetchError}</p>
<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"
>
Erneut versuchen

View 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