diff --git a/PASSWORD_SECURITY_ANALYSIS.md b/PASSWORD_SECURITY_ANALYSIS.md
new file mode 100644
index 0000000..f55af23
--- /dev/null
+++ b/PASSWORD_SECURITY_ANALYSIS.md
@@ -0,0 +1,74 @@
+# Passwort-Speicherung Sicherheitsanalyse
+
+## Aktuelle Implementierung
+
+### Wie werden Passwörter gespeichert?
+
+1. **Algorithmus**: `bcrypt` (golang.org/x/crypto/bcrypt)
+2. **Cost Factor**: `bcrypt.DefaultCost` (Wert: **10**)
+3. **Speicherung**:
+ - Feld: `password_hash TEXT NOT NULL` in SQLite
+ - Format: bcrypt Hash-String (enthält automatisch Salt + Hash)
+ - Beispiel: `$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy`
+
+4. **Passwortrichtlinie**:
+ - Mindestens 8 Zeichen
+ - Großbuchstaben erforderlich
+ - Kleinbuchstaben erforderlich
+ - Zahlen erforderlich
+ - Sonderzeichen erforderlich
+
+5. **Validierung**:
+ - Altes Passwort wird bei Änderung geprüft
+ - `bcrypt.CompareHashAndPassword()` für Login-Validierung
+
+## Entspricht es aktuellen Sicherheitsstandards?
+
+### ✅ **Gut implementiert:**
+
+1. **bcrypt ist ein sicherer, bewährter Algorithmus**
+ - Speziell für Passwort-Hashing entwickelt
+ - Verlangsamt Brute-Force-Angriffe durch anpassbare Rechenzeit
+ - Wird von OWASP und anderen Sicherheitsorganisationen empfohlen
+
+2. **Automatisches Salting**
+ - bcrypt generiert für jedes Passwort einen eindeutigen Salt
+ - Verhindert Rainbow-Table-Angriffe
+ - Salt wird im Hash-String mitgespeichert
+
+3. **Passwörter werden nie im Klartext gespeichert**
+ - Nur gehashte Werte in der Datenbank
+ - Einweg-Hashing (nicht reversibel)
+
+4. **Passwortrichtlinie vorhanden**
+ - Erzwingt starke Passwörter
+ - Mindestanforderungen erfüllt
+
+### ⚠️ **Verbesserungspotenzial:**
+
+1. **Cost Factor könnte erhöht werden**
+ - **Aktuell**: Cost 10 (DefaultCost)
+ - **Empfohlen 2024/2025**: Cost 12-14
+ - **Begründung**:
+ - Cost 10 war vor ~10 Jahren Standard
+ - Moderne Hardware ist schneller
+ - Cost 12-14 bietet besseren Schutz gegen Brute-Force
+ - Trade-off: Etwas langsamere Login-Zeit (~100-500ms), aber deutlich sicherer
+
+2. **Fehlende Sicherheitsfeatures** (optional, aber empfohlen):
+ - ❌ Rate Limiting für Login-Versuche (verhindert Brute-Force)
+ - ❌ Passwort-Historie (verhindert Wiederverwendung)
+ - ❌ Passwort-Ablaufzeit
+ - ❌ Account-Lockout nach fehlgeschlagenen Versuchen
+ - ❌ 2FA/MFA Support
+
+## Empfehlung
+
+Die aktuelle Implementierung ist **grundsätzlich sicher** und entspricht **modernen Standards**, aber:
+
+1. **Sofort umsetzbar**: Cost Factor von 10 auf 12-14 erhöhen
+2. **Mittelfristig**: Rate Limiting für Login-Versuche implementieren
+3. **Langfristig**: Zusätzliche Sicherheitsfeatures (2FA, Passwort-Historie)
+
+Soll ich den Cost Factor erhöhen?
+
diff --git a/backend/main.go b/backend/main.go
index ab497b4..ff784c8 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -982,18 +982,32 @@ func createSpaceHandler(w http.ResponseWriter, r *http.Request) {
return
}
- // Prüfe, ob der Benutzer FULL_ACCESS hat (ohne Space-Beschränkung)
- permissions, err := getUserPermissions(userID)
- if err != nil || len(permissions.Groups) == 0 {
- http.Error(w, "Keine Berechtigung zum Erstellen von Spaces", http.StatusForbidden)
+ // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
+ isAdmin, err := isUserAdmin(userID)
+ if err != nil {
+ log.Printf("Fehler beim Prüfen des Admin-Status: %v", err)
+ http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
return
}
- hasFullAccess := false
- for _, group := range permissions.Groups {
- if group.Permission == PermissionFullAccess {
- hasFullAccess = true
- break
+ // Prüfe, ob der Benutzer FULL_ACCESS hat (ohne Space-Beschränkung)
+ permissions, err := getUserPermissions(userID)
+ if err != nil {
+ http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
+ log.Printf("Fehler beim Abrufen der Berechtigungen: %v", err)
+ return
+ }
+
+ // Admin oder HasFullAccess erlaubt Space-Erstellung
+ hasFullAccess := isAdmin || permissions.HasFullAccess
+
+ // Wenn nicht Admin, prüfe auch Gruppen
+ if !isAdmin && len(permissions.Groups) > 0 {
+ for _, group := range permissions.Groups {
+ if group.Permission == PermissionFullAccess {
+ hasFullAccess = true
+ break
+ }
}
}
@@ -1669,17 +1683,31 @@ func deleteAllFqdnsGlobalHandler(w http.ResponseWriter, r *http.Request) {
return
}
- permissions, err := getUserPermissions(userID)
- if err != nil || len(permissions.Groups) == 0 {
- http.Error(w, "Keine Berechtigung zum Löschen aller FQDNs. Vollzugriff erforderlich.", http.StatusForbidden)
+ // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
+ isAdmin, err := isUserAdmin(userID)
+ if err != nil {
+ log.Printf("Fehler beim Prüfen des Admin-Status: %v", err)
+ http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
return
}
- hasFullAccess := false
- for _, group := range permissions.Groups {
- if group.Permission == PermissionFullAccess {
- hasFullAccess = true
- break
+ permissions, err := getUserPermissions(userID)
+ if err != nil {
+ http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
+ log.Printf("Fehler beim Abrufen der Berechtigungen: %v", err)
+ return
+ }
+
+ // Admin oder HasFullAccess erlaubt Löschen aller FQDNs
+ hasFullAccess := isAdmin || permissions.HasFullAccess
+
+ // Wenn nicht Admin, prüfe auch Gruppen
+ if !isAdmin && len(permissions.Groups) > 0 {
+ for _, group := range permissions.Groups {
+ if group.Permission == PermissionFullAccess {
+ hasFullAccess = true
+ break
+ }
}
}
@@ -1780,17 +1808,31 @@ func deleteAllCSRsHandler(w http.ResponseWriter, r *http.Request) {
return
}
- permissions, err := getUserPermissions(userID)
- if err != nil || len(permissions.Groups) == 0 {
- http.Error(w, "Keine Berechtigung zum Löschen aller CSRs. Vollzugriff erforderlich.", http.StatusForbidden)
+ // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
+ isAdmin, err := isUserAdmin(userID)
+ if err != nil {
+ log.Printf("Fehler beim Prüfen des Admin-Status: %v", err)
+ http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
return
}
- hasFullAccess := false
- for _, group := range permissions.Groups {
- if group.Permission == PermissionFullAccess {
- hasFullAccess = true
- break
+ permissions, err := getUserPermissions(userID)
+ if err != nil {
+ http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
+ log.Printf("Fehler beim Abrufen der Berechtigungen: %v", err)
+ return
+ }
+
+ // Admin oder HasFullAccess erlaubt Löschen aller CSRs
+ hasFullAccess := isAdmin || permissions.HasFullAccess
+
+ // Wenn nicht Admin, prüfe auch Gruppen
+ if !isAdmin && len(permissions.Groups) > 0 {
+ for _, group := range permissions.Groups {
+ if group.Permission == PermissionFullAccess {
+ hasFullAccess = true
+ break
+ }
}
}
@@ -4390,8 +4432,9 @@ func hasSpaceAccess(userID, spaceID string) (bool, error) {
return false, err
}
- // Wenn der Benutzer keine Gruppen hat, hat er keinen Zugriff
- if len(permissions.Groups) == 0 {
+ // Wenn der Benutzer keine Gruppen hat und nicht Admin ist, hat er keinen Zugriff
+ // Admins haben immer Zugriff (wird bereits oben geprüft)
+ if !isAdmin && len(permissions.Groups) == 0 {
return false, nil
}
@@ -4430,8 +4473,9 @@ func hasPermission(userID, spaceID string, requiredPermission PermissionLevel) (
return false, err
}
- // Wenn der Benutzer keine Gruppen hat, hat er keine Berechtigung
- if len(permissions.Groups) == 0 {
+ // Wenn der Benutzer keine Gruppen hat und nicht Admin ist, hat er keine Berechtigung
+ // Admins haben immer alle Berechtigungen (wird bereits oben geprüft)
+ if !isAdmin && len(permissions.Groups) == 0 {
return false, nil
}
@@ -4484,12 +4528,36 @@ func getAccessibleSpaceIDs(userID string) ([]string, error) {
return []string{}, nil
}
+ // Prüfe ob User Admin ist - Admins haben Zugriff auf alle Spaces
+ isAdmin, err := isUserAdmin(userID)
+ if err == nil && isAdmin {
+ // Hole alle Spaces für Admin
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
+ defer cancel()
+
+ rows, err := db.QueryContext(ctx, "SELECT id FROM spaces")
+ if err != nil {
+ return []string{}, err
+ }
+ defer rows.Close()
+
+ var spaceIDs []string
+ for rows.Next() {
+ var spaceID string
+ if err := rows.Scan(&spaceID); err == nil {
+ spaceIDs = append(spaceIDs, spaceID)
+ }
+ }
+ return spaceIDs, nil
+ }
+
permissions, err := getUserPermissions(userID)
if err != nil {
return []string{}, err
}
// Wenn der Benutzer keine Gruppen hat, hat er keinen Zugriff
+ // (Admin wurde bereits oben behandelt)
if len(permissions.Groups) == 0 {
return []string{}, nil
}
diff --git a/backend/spaces.db-shm b/backend/spaces.db-shm
index 99be2a6..22d9bfb 100644
Binary files a/backend/spaces.db-shm and b/backend/spaces.db-shm differ
diff --git a/backend/spaces.db-wal b/backend/spaces.db-wal
index 06c95e9..8b30f99 100644
Binary files a/backend/spaces.db-wal and b/backend/spaces.db-wal differ
diff --git a/backend/uploads/avatars/5ff86878-5277-4c18-af6b-1a63faaf3371.png b/backend/uploads/avatars/5ff86878-5277-4c18-af6b-1a63faaf3371.png
new file mode 100644
index 0000000..aaf893a
Binary files /dev/null and b/backend/uploads/avatars/5ff86878-5277-4c18-af6b-1a63faaf3371.png differ
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8ef5d4c..d030f7a 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -11,6 +11,7 @@ import Impressum from './pages/Impressum'
import Profile from './pages/Profile'
import Users from './pages/Users'
import Permissions from './pages/Permissions'
+import Providers from './pages/Providers'
import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs'
@@ -72,6 +73,48 @@ const AdminRoute = ({ children }) => {
return children
}
+// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
+const GroupRequiredRoute = ({ children }) => {
+ const { isAuthenticated, loading } = useAuth()
+ const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
+
+ if (loading || permissionsLoading) {
+ return (
+
+ )
+ }
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ // Admin oder User mit Gruppen haben Zugriff
+ const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
+
+ if (!hasGroups) {
+ return (
+
+ )
+ }
+
+ return children
+}
+
// Public Route Component (redirects to home if already logged in)
const PublicRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
@@ -105,13 +148,14 @@ const AppContent = () => {
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
} />
} />
} />
- } />
+ } />
+ } />
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 52ccb4f..9b7ce3a 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -7,15 +7,19 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuth()
- const { isAdmin } = usePermissions()
+ const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [expandedMenus, setExpandedMenus] = useState({})
+
+ // Prüfe ob User Berechtigungsgruppen hat
+ const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
+ // Menüpunkte - Home ist immer sichtbar, andere nur mit Gruppen
const menuItems = [
- { path: '/', label: 'Home', icon: '🏠' },
- { path: '/spaces', label: 'Spaces', icon: '📁' },
- { path: '/audit-logs', label: 'Audit Log', icon: '📋' },
- { path: '/impressum', label: 'Impressum', icon: 'ℹ️' },
- ]
+ { path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
+ { path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
+ { path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
+ { path: '/impressum', label: 'Impressum', icon: 'ℹ️', requiresGroups: true },
+ ].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
// Settings mit Unterpunkten
const settingsMenu = {
@@ -25,6 +29,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
subItems: [
{ path: '/settings/users', label: 'User', icon: '👥' },
{ path: '/settings/permissions', label: 'Berechtigungen', icon: '🔐' },
+ { path: '/settings/providers', label: 'SSL Provider', icon: '🔒' },
]
}
diff --git a/frontend/src/contexts/PermissionsContext.jsx b/frontend/src/contexts/PermissionsContext.jsx
index fb1e399..a59fbb8 100644
--- a/frontend/src/contexts/PermissionsContext.jsx
+++ b/frontend/src/contexts/PermissionsContext.jsx
@@ -1,8 +1,11 @@
-import { createContext, useContext, useState, useEffect, useCallback } from 'react'
+import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from './AuthContext'
const PermissionsContext = createContext(null)
+// Intervall für automatisches Neuladen der Permissions (30 Sekunden)
+const PERMISSIONS_REFRESH_INTERVAL = 30000
+
export const PermissionsProvider = ({ children }) => {
const { authFetch, isAuthenticated } = useAuth()
const [permissions, setPermissions] = useState({
@@ -17,40 +20,57 @@ export const PermissionsProvider = ({ children }) => {
canSignCSR: {},
})
const [loading, setLoading] = useState(true)
+ const intervalRef = useRef(null)
+ const isMountedRef = useRef(true)
- const fetchPermissions = useCallback(async () => {
+ const fetchPermissions = useCallback(async (isInitial = false) => {
if (!isAuthenticated) {
setLoading(false)
return
}
try {
- setLoading(true)
+ if (isInitial) {
+ setLoading(true)
+ }
const response = await authFetch('/api/user/permissions')
- if (response.ok) {
- const data = await response.json()
- setPermissions({
- isAdmin: data.isAdmin || false,
- hasFullAccess: data.hasFullAccess || false,
- accessibleSpaces: data.accessibleSpaces || [],
- canCreateSpace: data.permissions?.canCreateSpace || false,
- canDeleteSpace: data.permissions?.canDeleteSpace || false,
- canCreateFqdn: data.permissions?.canCreateFqdn || {},
- canDeleteFqdn: data.permissions?.canDeleteFqdn || {},
- canUploadCSR: data.permissions?.canUploadCSR || {},
- canSignCSR: data.permissions?.canSignCSR || {},
- })
+ if (response.ok && isMountedRef.current) {
+ try {
+ const data = await response.json()
+ // Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
+ setPermissions({
+ isAdmin: data.isAdmin || false,
+ hasFullAccess: data.hasFullAccess || false,
+ accessibleSpaces: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces : [],
+ canCreateSpace: data.permissions?.canCreateSpace || false,
+ canDeleteSpace: data.permissions?.canDeleteSpace || false,
+ canCreateFqdn: data.permissions?.canCreateFqdn || {},
+ canDeleteFqdn: data.permissions?.canDeleteFqdn || {},
+ canUploadCSR: data.permissions?.canUploadCSR || {},
+ canSignCSR: data.permissions?.canSignCSR || {},
+ })
+ } catch (parseErr) {
+ console.error('Error parsing permissions response:', parseErr)
+ // Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
+ }
+ } else if (response.status === 401 && isMountedRef.current) {
+ // Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
+ console.log('Unauthorized - permissions will be cleared by auth context')
}
} catch (err) {
console.error('Error fetching permissions:', err)
+ // Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
} finally {
- setLoading(false)
+ if (isInitial && isMountedRef.current) {
+ setLoading(false)
+ }
}
}, [isAuthenticated, authFetch])
+ // Initiales Laden der Permissions
useEffect(() => {
if (isAuthenticated) {
- fetchPermissions()
+ fetchPermissions(true)
} else {
setPermissions({
isAdmin: false,
@@ -67,6 +87,69 @@ export const PermissionsProvider = ({ children }) => {
}
}, [isAuthenticated, fetchPermissions])
+ // Automatisches Neuladen der Permissions im Hintergrund
+ useEffect(() => {
+ if (!isAuthenticated) {
+ return
+ }
+
+ // Starte Polling-Intervall
+ const startPolling = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ }
+ intervalRef.current = setInterval(() => {
+ if (isMountedRef.current && document.visibilityState === 'visible') {
+ fetchPermissions(false)
+ }
+ }, PERMISSIONS_REFRESH_INTERVAL)
+ }
+
+ // Handle visibility change - pausiere Polling wenn Tab versteckt ist
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ // Tab ist versteckt, stoppe Intervall
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ } else {
+ // Tab ist sichtbar, lade Permissions sofort und starte Polling
+ if (isMountedRef.current) {
+ fetchPermissions(false)
+ startPolling()
+ }
+ }
+ }
+
+ // Starte initiales Polling
+ startPolling()
+
+ // Event Listener für visibility change
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ // Cleanup
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [isAuthenticated, fetchPermissions])
+
+ // Cleanup beim Unmount
+ useEffect(() => {
+ isMountedRef.current = true
+ return () => {
+ isMountedRef.current = false
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [])
+
const canCreateSpace = () => permissions.canCreateSpace
const canDeleteSpace = (spaceId) => permissions.canDeleteSpace
const canCreateFqdn = (spaceId) => permissions.canCreateFqdn[spaceId] === true
@@ -75,11 +158,18 @@ export const PermissionsProvider = ({ children }) => {
const canSignCSR = (spaceId) => permissions.canSignCSR[spaceId] === true
const hasAccessToSpace = (spaceId) => permissions.accessibleSpaces.includes(spaceId)
+ // refreshPermissions Funktion, die auch loading state setzt
+ const refreshPermissions = useCallback(async () => {
+ await fetchPermissions(true)
+ }, [fetchPermissions])
+
const value = {
permissions,
loading,
- refreshPermissions: fetchPermissions,
+ refreshPermissions,
isAdmin: permissions.isAdmin,
+ hasFullAccess: permissions.hasFullAccess,
+ accessibleSpaces: permissions.accessibleSpaces,
canCreateSpace,
canDeleteSpace,
canCreateFqdn,
diff --git a/frontend/src/hooks/usePermissions.js b/frontend/src/hooks/usePermissions.js
deleted file mode 100644
index 24ea552..0000000
--- a/frontend/src/hooks/usePermissions.js
+++ /dev/null
@@ -1,3 +0,0 @@
-// Re-export from PermissionsContext for backward compatibility
-export { usePermissions } from '../contexts/PermissionsContext'
-
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
index 2cef111..0bcf51e 100644
--- a/frontend/src/pages/Home.jsx
+++ b/frontend/src/pages/Home.jsx
@@ -1,15 +1,31 @@
import { useEffect, useState, useRef, useCallback } from 'react'
+import { useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
-import ProvidersSection from '../components/ProvidersSection'
+import { usePermissions } from '../contexts/PermissionsContext'
const Home = () => {
const { authFetch } = useAuth()
+ const location = useLocation()
+ const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [data, setData] = useState(null)
const [stats, setStats] = useState(null)
const [loadingStats, setLoadingStats] = useState(true)
const [lastUpdate, setLastUpdate] = useState(null)
const intervalRef = useRef(null)
const isMountedRef = useRef(true)
+
+ // Prüfe ob User Berechtigungsgruppen hat
+ const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
+ const message = location.state?.message
+ const messageType = location.state?.type || 'info'
+
+ // Lösche location.state nach dem ersten Anzeigen
+ useEffect(() => {
+ if (location.state?.message) {
+ // Entferne die Nachricht aus dem state nach dem ersten Render
+ window.history.replaceState({}, document.title, location.pathname)
+ }
+ }, [location.state, location.pathname])
// Fetch stats function
const fetchStats = useCallback(async (isInitial = false) => {
@@ -188,7 +204,36 @@ const Home = () => {
Dies ist die Startseite der Certigo Addon Anwendung.
-
+ {/* Warnung wenn User keine Berechtigungsgruppen hat */}
+ {(!hasGroups || message) && (
+
+
+
+ {messageType === 'warning' ? '⚠️' : 'ℹ️'}
+
+
+
+ {messageType === 'warning' ? 'Keine Berechtigungsgruppe' : 'Information'}
+
+
+ {message || "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator, um Zugriff auf die Anwendung zu erhalten."}
+
+
+
+
+ )}
+
+
{/* Stats Dashboard */}
@@ -306,9 +351,6 @@ const Home = () => {
Lade Daten...
)}
-
- {/* SSL Certificate Providers */}
-
diff --git a/frontend/src/pages/Permissions.jsx b/frontend/src/pages/Permissions.jsx
index b7bec68..7fe3289 100644
--- a/frontend/src/pages/Permissions.jsx
+++ b/frontend/src/pages/Permissions.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
-import { usePermissions } from '../hooks/usePermissions'
+import { usePermissions } from '../contexts/PermissionsContext'
const Permissions = () => {
const { authFetch } = useAuth()
diff --git a/frontend/src/pages/Providers.jsx b/frontend/src/pages/Providers.jsx
new file mode 100644
index 0000000..73b4a67
--- /dev/null
+++ b/frontend/src/pages/Providers.jsx
@@ -0,0 +1,342 @@
+import { useState, useEffect } from 'react'
+import { useAuth } from '../contexts/AuthContext'
+
+const Providers = () => {
+ const { authFetch } = useAuth()
+ const [providers, setProviders] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showConfigModal, setShowConfigModal] = useState(false)
+ const [selectedProvider, setSelectedProvider] = useState(null)
+ const [configValues, setConfigValues] = useState({})
+ const [testing, setTesting] = useState(false)
+ const [testResult, setTestResult] = useState(null)
+
+ useEffect(() => {
+ fetchProviders()
+ }, [authFetch])
+
+ const fetchProviders = async () => {
+ try {
+ const response = await authFetch('/api/providers')
+ if (response.ok) {
+ const data = await response.json()
+ // Definiere feste Reihenfolge der Provider
+ const providerOrder = ['dummy-ca', 'autodns', 'hetzner']
+ const sortedProviders = providerOrder
+ .map(id => data.find(p => p.id === id))
+ .filter(p => p !== undefined)
+ .concat(data.filter(p => !providerOrder.includes(p.id)))
+ setProviders(sortedProviders)
+ }
+ } catch (err) {
+ console.error('Error fetching providers:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleToggleProvider = async (providerId, currentEnabled) => {
+ try {
+ const response = await authFetch(`/api/providers/${providerId}/enabled`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ enabled: !currentEnabled }),
+ })
+
+ if (response.ok) {
+ fetchProviders()
+ } else {
+ alert('Fehler beim Ändern des Provider-Status')
+ }
+ } catch (err) {
+ console.error('Error toggling provider:', err)
+ alert('Fehler beim Ändern des Provider-Status')
+ }
+ }
+
+ const handleOpenConfig = async (provider) => {
+ setSelectedProvider(provider)
+ setTestResult(null)
+
+ // Lade aktuelle Konfiguration
+ try {
+ const response = await authFetch(`/api/providers/${provider.id}`)
+ if (response.ok) {
+ const data = await response.json()
+ // Initialisiere Config-Werte
+ const initialValues = {}
+ provider.settings.forEach(setting => {
+ if (data.config && data.config[setting.name] !== undefined) {
+ // Wenn Wert "***" ist, bedeutet das, dass es ein Passwort ist - leer lassen
+ initialValues[setting.name] = data.config[setting.name] === '***' ? '' : data.config[setting.name]
+ } else {
+ initialValues[setting.name] = setting.default || ''
+ }
+ })
+ setConfigValues(initialValues)
+ }
+ } catch (err) {
+ console.error('Error fetching provider config:', err)
+ // Initialisiere mit leeren Werten
+ const initialValues = {}
+ provider.settings.forEach(setting => {
+ initialValues[setting.name] = setting.default || ''
+ })
+ setConfigValues(initialValues)
+ }
+
+ setShowConfigModal(true)
+ }
+
+ const handleCloseConfig = () => {
+ setShowConfigModal(false)
+ setSelectedProvider(null)
+ setConfigValues({})
+ setTestResult(null)
+ }
+
+ const handleConfigChange = (name, value) => {
+ setConfigValues({
+ ...configValues,
+ [name]: value,
+ })
+ }
+
+ const handleTestConnection = async () => {
+ if (!selectedProvider) return
+
+ setTesting(true)
+ setTestResult(null)
+
+ try {
+ const response = await authFetch(`/api/providers/${selectedProvider.id}/test`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ settings: configValues }),
+ })
+
+ const result = await response.json()
+ setTestResult(result)
+ } catch (err) {
+ console.error('Error testing connection:', err)
+ setTestResult({
+ success: false,
+ message: 'Fehler beim Testen der Verbindung',
+ })
+ } finally {
+ setTesting(false)
+ }
+ }
+
+ const handleSaveConfig = async () => {
+ if (!selectedProvider) return
+
+ try {
+ const response = await authFetch(`/api/providers/${selectedProvider.id}/config`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ settings: configValues }),
+ })
+
+ if (response.ok) {
+ handleCloseConfig()
+ fetchProviders()
+ } else {
+ const error = await response.text()
+ alert(`Fehler beim Speichern: ${error}`)
+ }
+ } catch (err) {
+ console.error('Error saving config:', err)
+ alert('Fehler beim Speichern der Konfiguration')
+ }
+ }
+
+ return (
+
+
+
SSL Certificate Providers
+
+ Verwalten Sie Ihre SSL-Zertifikats-Provider und deren Konfiguration.
+
+
+ {loading ? (
+
+ ) : (
+
+
+ {providers.map((provider) => (
+
+
+
+
+ {provider.displayName}
+
+
+ {provider.description}
+
+
+
+
handleOpenConfig(provider)}
+ className="p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition-colors"
+ title="Konfiguration"
+ aria-label="Konfiguration"
+ >
+
+
+
+
+
+
+ handleToggleProvider(provider.id, provider.enabled)}
+ className="sr-only peer"
+ />
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Configuration Modal */}
+ {showConfigModal && selectedProvider && (
+
+
+
+
+ {selectedProvider.displayName} - Konfiguration
+
+
+
+
+
+
+
+
+
+ {selectedProvider.settings.length > 0 ? (
+ selectedProvider.settings.map((setting) => (
+
+
+ {setting.label}
+ {setting.required && * }
+
+ {setting.description && (
+
{setting.description}
+ )}
+ {setting.type === 'password' ? (
+
handleConfigChange(setting.name, e.target.value)}
+ 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 transition-all duration-300"
+ placeholder={setting.label}
+ required={setting.required}
+ />
+ ) : (
+
handleConfigChange(setting.name, e.target.value)}
+ 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 transition-all duration-300"
+ placeholder={setting.label}
+ required={setting.required}
+ />
+ )}
+
+ ))
+ ) : (
+
+ Dieser Provider benötigt keine Konfiguration.
+
+ )}
+
+
+ {testResult && (
+
+
+ {testResult.success ? '✅' : '❌'} {testResult.message}
+
+
+ )}
+
+
+ {selectedProvider.settings.length > 0 && (
+
+ {testing ? 'Teste...' : 'Verbindung testen'}
+
+ )}
+
+ Speichern
+
+
+ Abbrechen
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default Providers
+
diff --git a/frontend/src/pages/SpaceDetail.jsx b/frontend/src/pages/SpaceDetail.jsx
index 0f28d2d..353c5b3 100644
--- a/frontend/src/pages/SpaceDetail.jsx
+++ b/frontend/src/pages/SpaceDetail.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
-import { usePermissions } from '../hooks/usePermissions'
+import { usePermissions } from '../contexts/PermissionsContext'
const SpaceDetail = () => {
const { id } = useParams()
@@ -423,40 +423,64 @@ const SpaceDetail = () => {
}
const handleViewCertificates = async (fqdn) => {
+ if (!fqdn || !fqdn.id) {
+ console.error('Invalid FQDN provided to handleViewCertificates')
+ return
+ }
+
setSelectedFqdn(fqdn)
setLoadingCertificates(true)
setCertificates([])
+ setShowCertificatesModal(true) // Öffne Modal sofort, auch wenn noch geladen wird
try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
+
if (response.ok) {
- const certs = await response.json()
- setCertificates(certs)
+ try {
+ const certs = await response.json()
+ // Stelle sicher, dass certs ein Array ist
+ setCertificates(Array.isArray(certs) ? certs : [])
+ } catch (parseErr) {
+ console.error('Error parsing certificates response:', parseErr)
+ setCertificates([])
+ }
} else {
- console.error('Fehler beim Laden der Zertifikate')
+ // Bei Fehler-Response (404, 403, etc.) setze leeres Array
+ console.error('Fehler beim Laden der Zertifikate:', response.status, response.statusText)
+ setCertificates([])
}
} catch (err) {
console.error('Error fetching certificates:', err)
+ setCertificates([])
} finally {
setLoadingCertificates(false)
- setShowCertificatesModal(true)
}
}
const handleRefreshCertificate = async (cert) => {
+ if (!cert || !cert.id || !selectedFqdn || !selectedFqdn.id) {
+ console.error('Invalid certificate or FQDN for refresh')
+ return
+ }
+
setRefreshingCertificate(cert.id)
try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, {
method: 'POST'
})
if (response.ok) {
- const result = await response.json()
- // Aktualisiere Zertifikat in der Liste
- setCertificates(prev => prev.map(c =>
- c.id === cert.id
- ? { ...c, certificatePEM: result.certificatePEM }
- : c
- ))
+ try {
+ const result = await response.json()
+ // Aktualisiere Zertifikat in der Liste
+ setCertificates(prev => prev.map(c =>
+ c.id === cert.id
+ ? { ...c, certificatePEM: result.certificatePEM }
+ : c
+ ))
+ } catch (parseErr) {
+ console.error('Error parsing refresh response:', parseErr)
+ }
}
} catch (err) {
console.error('Error refreshing certificate:', err)
@@ -1478,10 +1502,13 @@ const SpaceDetail = () => {
-
Zertifikate für {selectedFqdn.fqdn}
+
+ Zertifikate für {selectedFqdn?.fqdn || 'Unbekannter FQDN'}
+
@@ -1501,64 +1528,80 @@ const SpaceDetail = () => {
{certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden
- {certificates.map((cert, index) => (
-
-
-
-
-
- #{certificates.length - index}
-
-
CA-Zertifikat-ID: {cert.certificateId}
-
-
-
- Interne UID: {' '}
- {cert.id}
-
-
- Erstellt: {' '}
- {new Date(cert.createdAt).toLocaleString('de-DE')}
-
-
- Status: {' '}
-
- {cert.status}
+ {certificates.map((cert, index) => {
+ // Sicherstellen, dass cert ein gültiges Objekt ist
+ if (!cert || !cert.id) {
+ return null
+ }
+
+ const certId = cert.id || 'unknown'
+ const certCertificateId = cert.certificateId || 'N/A'
+ const certCreatedAt = cert.createdAt ? new Date(cert.createdAt) : null
+ const certStatus = cert.status || 'unknown'
+ const certProviderId = cert.providerId
+ const certPEM = cert.certificatePEM
+
+ return (
+
+
+
+
+
+ #{certificates.length - index}
-
- {cert.providerId && (
-
- Provider: {' '}
- {cert.providerId}
+
CA-Zertifikat-ID: {certCertificateId}
+
+
+
+ Interne UID: {' '}
+ {certId}
- )}
+ {certCreatedAt && !isNaN(certCreatedAt.getTime()) && (
+
+ Erstellt: {' '}
+ {certCreatedAt.toLocaleString('de-DE')}
+
+ )}
+
+ Status: {' '}
+
+ {certStatus}
+
+
+ {certProviderId && (
+
+ Provider: {' '}
+ {certProviderId}
+
+ )}
+
+
handleRefreshCertificate(cert)}
+ disabled={refreshingCertificate === certId || !selectedFqdn}
+ className="ml-4 px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
+ title="Zertifikat von CA abrufen"
+ >
+ {refreshingCertificate === certId ? 'Aktualisiere...' : 'Aktualisieren'}
+
-
handleRefreshCertificate(cert)}
- disabled={refreshingCertificate === cert.id}
- className="ml-4 px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
- title="Zertifikat von CA abrufen"
- >
- {refreshingCertificate === cert.id ? 'Aktualisiere...' : 'Aktualisieren'}
-
+ {certPEM && (
+
+
Zertifikat (PEM):
+
+ {certPEM}
+
+
+ )}
- {cert.certificatePEM && (
-
-
Zertifikat (PEM):
-
- {cert.certificatePEM}
-
-
- )}
-
- ))}
+ )
+ })}
)}
diff --git a/frontend/src/pages/Spaces.jsx b/frontend/src/pages/Spaces.jsx
index 8997112..1734c37 100644
--- a/frontend/src/pages/Spaces.jsx
+++ b/frontend/src/pages/Spaces.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
-import { usePermissions } from '../hooks/usePermissions'
+import { usePermissions } from '../contexts/PermissionsContext'
const Spaces = () => {
const navigate = useNavigate()
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx
index dd5d106..69218e0 100644
--- a/frontend/src/pages/Users.jsx
+++ b/frontend/src/pages/Users.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
-import { usePermissions } from '../hooks/usePermissions'
+import { usePermissions } from '../contexts/PermissionsContext'
const Users = () => {
const { authFetch } = useAuth()