Compare commits

..

5 Commits

15 changed files with 840 additions and 135 deletions

View File

@@ -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?

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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 (
<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>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
if (!hasGroups) {
return (
<Navigate
to="/"
replace
state={{
message: "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.",
type: "warning"
}}
/>
)
}
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 = () => {
<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="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} />
<Route path="/settings/permissions" element={<AdminRoute><Permissions /></AdminRoute>} />
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} />
<Route path="/settings/providers" element={<AdminRoute><Providers /></AdminRoute>} />
<Route path="/audit-logs" element={<GroupRequiredRoute><AuditLogs /></GroupRequiredRoute>} />
</Routes>
</div>
<Footer />

View File

@@ -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: '🔒' },
]
}

View File

@@ -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,

View File

@@ -1,3 +0,0 @@
// Re-export from PermissionsContext for backward compatibility
export { usePermissions } from '../contexts/PermissionsContext'

View File

@@ -1,9 +1,12 @@
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)
@@ -11,6 +14,19 @@ const Home = () => {
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) => {
try {
@@ -188,7 +204,36 @@ const Home = () => {
Dies ist die Startseite der Certigo Addon Anwendung.
</p>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Warnung wenn User keine Berechtigungsgruppen hat */}
{(!hasGroups || message) && (
<div className={`mb-6 p-4 rounded-lg border ${
messageType === 'warning'
? 'bg-yellow-500/20 border-yellow-500/50'
: 'bg-blue-500/20 border-blue-500/50'
}`}>
<div className="flex items-start gap-3">
<div className={`text-2xl flex-shrink-0 ${
messageType === 'warning' ? 'text-yellow-400' : 'text-blue-400'
}`}>
{messageType === 'warning' ? '⚠️' : ''}
</div>
<div className="flex-1">
<p className={`font-semibold mb-1 ${
messageType === 'warning' ? 'text-yellow-300' : 'text-blue-300'
}`}>
{messageType === 'warning' ? 'Keine Berechtigungsgruppe' : 'Information'}
</p>
<p className={`text-sm ${
messageType === 'warning' ? 'text-yellow-200' : 'text-blue-200'
}`}>
{message || "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator, um Zugriff auf die Anwendung zu erhalten."}
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Stats Dashboard */}
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<div className="flex items-center justify-between mb-4">
@@ -306,9 +351,6 @@ const Home = () => {
<p className="text-slate-400">Lade Daten...</p>
)}
</div>
{/* SSL Certificate Providers */}
<ProvidersSection />
</div>
</div>
</div>

View File

@@ -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()

View File

@@ -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 (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold text-white mb-4">SSL Certificate Providers</h1>
<p className="text-lg text-slate-200 mb-8">
Verwalten Sie Ihre SSL-Zertifikats-Provider und deren Konfiguration.
</p>
{loading ? (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<p className="text-slate-400">Lade Provider...</p>
</div>
) : (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.id}
className="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50 transition-all duration-300"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1 transition-colors duration-300">
{provider.displayName}
</h3>
<p className="text-sm text-slate-300 mb-2 transition-colors duration-300">
{provider.description}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => 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"
>
<svg
className="w-5 h-5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.enabled}
onChange={() => handleToggleProvider(provider.id, provider.enabled)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 transition-all duration-300"></div>
</label>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Configuration Modal */}
{showConfigModal && selectedProvider && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-colors duration-300">
<div className="bg-slate-800 rounded-xl shadow-2xl border border-slate-600/50 max-w-2xl w-full p-6 transition-all duration-300">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white transition-colors duration-300">
{selectedProvider.displayName} - Konfiguration
</h3>
<button
onClick={handleCloseConfig}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition-colors"
aria-label="Schließen"
>
<svg
className="w-5 h-5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4 mb-6">
{selectedProvider.settings.length > 0 ? (
selectedProvider.settings.map((setting) => (
<div key={setting.name}>
<label className="block text-sm font-medium text-slate-200 mb-2 transition-colors duration-300">
{setting.label}
{setting.required && <span className="text-red-400 ml-1">*</span>}
</label>
{setting.description && (
<p className="text-xs text-slate-400 mb-2 transition-colors duration-300">{setting.description}</p>
)}
{setting.type === 'password' ? (
<input
type="password"
value={configValues[setting.name] || ''}
onChange={(e) => 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}
/>
) : (
<input
type={setting.type || 'text'}
value={configValues[setting.name] || ''}
onChange={(e) => 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}
/>
)}
</div>
))
) : (
<p className="text-slate-300 text-center py-4 transition-colors duration-300">
Dieser Provider benötigt keine Konfiguration.
</p>
)}
</div>
{testResult && (
<div
className={`mb-4 p-4 rounded-lg border ${
testResult.success
? 'bg-green-500/20 border-green-500/50'
: 'bg-red-500/20 border-red-500/50'
}`}
>
<p
className={`text-sm ${
testResult.success ? 'text-green-300' : 'text-red-300'
}`}
>
{testResult.success ? '✅' : '❌'} {testResult.message}
</p>
</div>
)}
<div className="flex gap-3">
{selectedProvider.settings.length > 0 && (
<button
onClick={handleTestConnection}
disabled={testing}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200"
>
{testing ? 'Teste...' : 'Verbindung testen'}
</button>
)}
<button
onClick={handleSaveConfig}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200"
>
Speichern
</button>
<button
onClick={handleCloseConfig}
className="px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default Providers

View File

@@ -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 = () => {
<div className="bg-slate-800 rounded-lg border border-slate-600 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">Zertifikate für {selectedFqdn.fqdn}</h2>
<h2 className="text-2xl font-bold text-white">
Zertifikate für {selectedFqdn?.fqdn || 'Unbekannter FQDN'}
</h2>
<button
onClick={closeCertificatesModal}
className="text-slate-400 hover:text-white transition-colors"
aria-label="Schließen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -1501,64 +1528,80 @@ const SpaceDetail = () => {
{certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden
</p>
</div>
{certificates.map((cert, index) => (
<div key={cert.id} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-purple-400 bg-purple-500/20 px-2 py-1 rounded">
#{certificates.length - index}
</span>
<h4 className="text-white font-semibold">CA-Zertifikat-ID: {cert.certificateId}</h4>
</div>
<div className="space-y-1">
<p className="text-slate-400 text-xs">
<span className="font-semibold text-slate-300">Interne UID:</span>{' '}
<span className="font-mono text-xs">{cert.id}</span>
</p>
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Erstellt:</span>{' '}
{new Date(cert.createdAt).toLocaleString('de-DE')}
</p>
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Status:</span>{' '}
<span className={`inline-block px-2 py-0.5 rounded text-xs ${
cert.status === 'issued'
? 'bg-green-500/20 text-green-400'
: cert.status === 'pending'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-red-500/20 text-red-400'
}`}>
{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 (
<div key={certId} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-purple-400 bg-purple-500/20 px-2 py-1 rounded">
#{certificates.length - index}
</span>
</p>
{cert.providerId && (
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Provider:</span>{' '}
{cert.providerId}
<h4 className="text-white font-semibold">CA-Zertifikat-ID: {certCertificateId}</h4>
</div>
<div className="space-y-1">
<p className="text-slate-400 text-xs">
<span className="font-semibold text-slate-300">Interne UID:</span>{' '}
<span className="font-mono text-xs">{certId}</span>
</p>
)}
{certCreatedAt && !isNaN(certCreatedAt.getTime()) && (
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Erstellt:</span>{' '}
{certCreatedAt.toLocaleString('de-DE')}
</p>
)}
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Status:</span>{' '}
<span className={`inline-block px-2 py-0.5 rounded text-xs ${
certStatus === 'issued'
? 'bg-green-500/20 text-green-400'
: certStatus === 'pending'
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-red-500/20 text-red-400'
}`}>
{certStatus}
</span>
</p>
{certProviderId && (
<p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Provider:</span>{' '}
{certProviderId}
</p>
)}
</div>
</div>
<button
onClick={() => 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'}
</button>
</div>
<button
onClick={() => 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'}
</button>
{certPEM && (
<div className="mt-3">
<h5 className="text-sm font-semibold text-slate-300 mb-2">Zertifikat (PEM):</h5>
<pre className="text-xs text-slate-200 bg-slate-900/50 p-3 rounded overflow-auto max-h-60 font-mono">
{certPEM}
</pre>
</div>
)}
</div>
{cert.certificatePEM && (
<div className="mt-3">
<h5 className="text-sm font-semibold text-slate-300 mb-2">Zertifikat (PEM):</h5>
<pre className="text-xs text-slate-200 bg-slate-900/50 p-3 rounded overflow-auto max-h-60 font-mono">
{cert.certificatePEM}
</pre>
</div>
)}
</div>
))}
)
})}
</div>
)}
</div>

View File

@@ -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()

View File

@@ -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()