Compare commits

..

9 Commits

Author SHA1 Message Date
3ea0ebae1b Merge pull request 'development' (#8) from development into main
Reviewed-on: #8
2025-11-21 23:33:13 +00:00
2f2be739f2 chore: update gitignore and stop tracking sqlite runtime files 2025-11-21 23:56:03 +01:00
d1e9c2433c added fix for empty CSR history after uploading ones
deleted:    backend/spaces.db-shm
	deleted:    backend/spaces.db-wal
	modified:   frontend/src/App.jsx
	modified:   frontend/src/components/Sidebar.jsx
	new file:   frontend/src/hooks/usePermissions.js
	modified:   frontend/src/pages/Home.jsx
	modified:   frontend/src/pages/Profile.jsx
	modified:   frontend/src/pages/SpaceDetail.jsx
2025-11-21 23:50:12 +01:00
f0c23cad35 Merge pull request 'Merge pull request 'certigo_release/1.0' from development into main' (#6) from main into development
Reviewed-on: #6
2025-11-21 22:38:00 +00:00
39148bbb56 Merge pull request 'certigo_release/1.0' from development into main
Reviewed-on: #5
2025-11-21 22:14:18 +00:00
e96fa8f367 Merge pull request 'permission missing fix when certs is empty' (#4) from fix/emptyCerts into development
Reviewed-on: #4
2025-11-21 22:05:07 +00:00
97163becfa permission missing fix when certs is empty 2025-11-21 23:04:25 +01:00
16043e2577 Merge pull request 'fix/sslProviderPermission' (#3) from fix/sslProviderPermission into development
Reviewed-on: #3
2025-11-21 01:31:07 +00:00
e3a2ccb82d fixed ssl provider section view/edit permission 2025-11-21 02:30:11 +01:00
18 changed files with 1229 additions and 166 deletions

327
.gitignore vendored
View File

@@ -1,29 +1,342 @@
# ============================================
# Certigo Addon - Comprehensive .gitignore
# ============================================
# ============================================
# Go / Backend
# ============================================
# Binaries and executables
*.exe
*.exe~
*.dll
*.so
*.dylib
backend/bin/
backend/myapp
backend/certigo-addon
backend/certigo-addon-*
/tmp/certigo-addon-*
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.html
coverage.txt
# Go workspace file
go.work
go.work.sum
# Go module cache (optional, but recommended for CI/CD)
# .go/
# ============================================
# Node.js / Frontend
# ============================================
# Dependencies # Dependencies
node_modules/ node_modules/
frontend/node_modules/ frontend/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Build outputs # Build outputs
dist/ dist/
dist-ssr/
frontend/dist/ frontend/dist/
backend/bin/ *.local
# Vite
.vite/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# ============================================
# Database # Database
# ============================================
# SQLite databases
*.db *.db
*.db-shm
*.db-wal
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
backend/spaces.db backend/spaces.db
backend/*.db
# Environment variables # Database backups
.env *.sql.backup
.env.local *.db.backup
# IDE # ============================================
# Uploads & User-generated Content
# ============================================
# User uploads (avatars, files, etc.)
backend/uploads/
backend/uploads/**
!backend/uploads/.gitkeep
frontend/public/uploads/
# ============================================
# Logs
# ============================================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
backend/*.log
# ============================================
# IDE & Editors
# ============================================
# VSCode
.vscode/ .vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# IntelliJ IDEA / WebStorm
.idea/ .idea/
*.iml
*.iws
*.ipr
out/
# Sublime Text
*.sublime-project
*.sublime-workspace
# Vim
*.swp *.swp
*.swo *.swo
*~
.vim/
# OS # Emacs
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# ============================================
# OS Files
# ============================================
# macOS
.DS_Store .DS_Store
Thumbs.db .AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# ============================================
# Temporary & Cache Files
# ============================================
# Temporary files
*.tmp
*.temp
*.bak
*.backup
*.swp
*~.nib
*.orig
# Cache directories
.cache/
.parcel-cache/
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
.yarn-integrity
# ============================================
# Testing & Coverage
# ============================================
# Test coverage
coverage/
*.lcov
.nyc_output/
.coverage/
htmlcov/
.pytest_cache/
.tox/
# Jest
.jest/
# ============================================
# Build Tools & CI/CD
# ============================================
# Build artifacts
build/
out/
target/
.next/
.nuxt/
.cache/
# CI/CD
.github/workflows/*.yml.local
.circleci/
.travis.yml.local
# ============================================
# Security & Secrets
# ============================================
# Secrets and credentials
*.pem
*.key
*.crt
*.cert
secrets/
.secrets/
*.secret
config/secrets.*
# API keys and tokens
.env.secret
.env.production
.env.staging
# ============================================
# Documentation Build
# ============================================
# Generated documentation
docs/_build/
site/
# ============================================
# Misc
# ============================================
# Package manager lock files (optional - uncomment if you want to ignore)
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development
.env.test
.env.production
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# ============================================
# Project-specific
# ============================================
# OpenAPI generated files (if any)
backend/generated/
backend/api/
# Provider test outputs
backend/test-outputs/
# Script outputs
backend/scripts/output/
# Keep directory structure but ignore contents
!backend/uploads/.gitkeep
!backend/config/providers/.gitkeep

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 return
} }
// Prüfe, ob der Benutzer FULL_ACCESS hat (ohne Space-Beschränkung) // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
permissions, err := getUserPermissions(userID) isAdmin, err := isUserAdmin(userID)
if err != nil || len(permissions.Groups) == 0 { if err != nil {
http.Error(w, "Keine Berechtigung zum Erstellen von Spaces", http.StatusForbidden) log.Printf("Fehler beim Prüfen des Admin-Status: %v", err)
http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
return return
} }
hasFullAccess := false // Prüfe, ob der Benutzer FULL_ACCESS hat (ohne Space-Beschränkung)
for _, group := range permissions.Groups { permissions, err := getUserPermissions(userID)
if group.Permission == PermissionFullAccess { if err != nil {
hasFullAccess = true http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
break 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 return
} }
permissions, err := getUserPermissions(userID) // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
if err != nil || len(permissions.Groups) == 0 { isAdmin, err := isUserAdmin(userID)
http.Error(w, "Keine Berechtigung zum Löschen aller FQDNs. Vollzugriff erforderlich.", http.StatusForbidden) 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 return
} }
hasFullAccess := false permissions, err := getUserPermissions(userID)
for _, group := range permissions.Groups { if err != nil {
if group.Permission == PermissionFullAccess { http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
hasFullAccess = true log.Printf("Fehler beim Abrufen der Berechtigungen: %v", err)
break 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 return
} }
permissions, err := getUserPermissions(userID) // Prüfe ob User Admin ist - Admins haben immer Vollzugriff
if err != nil || len(permissions.Groups) == 0 { isAdmin, err := isUserAdmin(userID)
http.Error(w, "Keine Berechtigung zum Löschen aller CSRs. Vollzugriff erforderlich.", http.StatusForbidden) 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 return
} }
hasFullAccess := false permissions, err := getUserPermissions(userID)
for _, group := range permissions.Groups { if err != nil {
if group.Permission == PermissionFullAccess { http.Error(w, "Fehler beim Abrufen der Berechtigungen", http.StatusInternalServerError)
hasFullAccess = true log.Printf("Fehler beim Abrufen der Berechtigungen: %v", err)
break 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 return false, err
} }
// Wenn der Benutzer keine Gruppen hat, hat er keinen Zugriff // Wenn der Benutzer keine Gruppen hat und nicht Admin ist, hat er keinen Zugriff
if len(permissions.Groups) == 0 { // Admins haben immer Zugriff (wird bereits oben geprüft)
if !isAdmin && len(permissions.Groups) == 0 {
return false, nil return false, nil
} }
@@ -4430,8 +4473,9 @@ func hasPermission(userID, spaceID string, requiredPermission PermissionLevel) (
return false, err return false, err
} }
// Wenn der Benutzer keine Gruppen hat, hat er keine Berechtigung // Wenn der Benutzer keine Gruppen hat und nicht Admin ist, hat er keine Berechtigung
if len(permissions.Groups) == 0 { // Admins haben immer alle Berechtigungen (wird bereits oben geprüft)
if !isAdmin && len(permissions.Groups) == 0 {
return false, nil return false, nil
} }
@@ -4484,12 +4528,36 @@ func getAccessibleSpaceIDs(userID string) ([]string, error) {
return []string{}, nil 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) permissions, err := getUserPermissions(userID)
if err != nil { if err != nil {
return []string{}, err return []string{}, err
} }
// Wenn der Benutzer keine Gruppen hat, hat er keinen Zugriff // Wenn der Benutzer keine Gruppen hat, hat er keinen Zugriff
// (Admin wurde bereits oben behandelt)
if len(permissions.Groups) == 0 { if len(permissions.Groups) == 0 {
return []string{}, nil return []string{}, nil
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { PermissionsProvider, usePermissions } from './contexts/PermissionsContext' import { PermissionsProvider } from './contexts/PermissionsContext'
import { usePermissions } from './hooks/usePermissions'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import Footer from './components/Footer' import Footer from './components/Footer'
import Home from './pages/Home' import Home from './pages/Home'
@@ -11,6 +12,7 @@ import Impressum from './pages/Impressum'
import Profile from './pages/Profile' import Profile from './pages/Profile'
import Users from './pages/Users' import Users from './pages/Users'
import Permissions from './pages/Permissions' import Permissions from './pages/Permissions'
import Providers from './pages/Providers'
import Login from './pages/Login' import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
@@ -72,6 +74,48 @@ const AdminRoute = ({ children }) => {
return 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) // Public Route Component (redirects to home if already logged in)
const PublicRoute = ({ children }) => { const PublicRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth() const { isAuthenticated, loading } = useAuth()
@@ -105,13 +149,14 @@ const AppContent = () => {
<Routes> <Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} /> <Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} /> <Route path="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} /> <Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} /> <Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} /> <Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} />
<Route path="/settings/permissions" element={<AdminRoute><Permissions /></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> </Routes>
</div> </div>
<Footer /> <Footer />

View File

@@ -1,21 +1,25 @@
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../contexts/PermissionsContext' import { usePermissions } from '../hooks/usePermissions'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
const Sidebar = ({ isOpen, setIsOpen }) => { const Sidebar = ({ isOpen, setIsOpen }) => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { isAdmin } = usePermissions() const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [expandedMenus, setExpandedMenus] = useState({}) 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 = [ const menuItems = [
{ path: '/', label: 'Home', icon: '🏠' }, { path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
{ path: '/spaces', label: 'Spaces', icon: '📁' }, { path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
{ path: '/audit-logs', label: 'Audit Log', icon: '📋' }, { path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
{ path: '/impressum', label: 'Impressum', icon: '' }, { path: '/impressum', label: 'Impressum', icon: '', requiresGroups: true },
] ].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
// Settings mit Unterpunkten // Settings mit Unterpunkten
const settingsMenu = { const settingsMenu = {
@@ -25,6 +29,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
subItems: [ subItems: [
{ path: '/settings/users', label: 'User', icon: '👥' }, { path: '/settings/users', label: 'User', icon: '👥' },
{ path: '/settings/permissions', label: 'Berechtigungen', 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' import { useAuth } from './AuthContext'
const PermissionsContext = createContext(null) const PermissionsContext = createContext(null)
// Intervall für automatisches Neuladen der Permissions (30 Sekunden)
const PERMISSIONS_REFRESH_INTERVAL = 30000
export const PermissionsProvider = ({ children }) => { export const PermissionsProvider = ({ children }) => {
const { authFetch, isAuthenticated } = useAuth() const { authFetch, isAuthenticated } = useAuth()
const [permissions, setPermissions] = useState({ const [permissions, setPermissions] = useState({
@@ -17,40 +20,57 @@ export const PermissionsProvider = ({ children }) => {
canSignCSR: {}, canSignCSR: {},
}) })
const [loading, setLoading] = useState(true) 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) { if (!isAuthenticated) {
setLoading(false) setLoading(false)
return return
} }
try { try {
setLoading(true) if (isInitial) {
setLoading(true)
}
const response = await authFetch('/api/user/permissions') const response = await authFetch('/api/user/permissions')
if (response.ok) { if (response.ok && isMountedRef.current) {
const data = await response.json() try {
setPermissions({ const data = await response.json()
isAdmin: data.isAdmin || false, // Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
hasFullAccess: data.hasFullAccess || false, setPermissions({
accessibleSpaces: data.accessibleSpaces || [], isAdmin: data.isAdmin || false,
canCreateSpace: data.permissions?.canCreateSpace || false, hasFullAccess: data.hasFullAccess || false,
canDeleteSpace: data.permissions?.canDeleteSpace || false, accessibleSpaces: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces : [],
canCreateFqdn: data.permissions?.canCreateFqdn || {}, canCreateSpace: data.permissions?.canCreateSpace || false,
canDeleteFqdn: data.permissions?.canDeleteFqdn || {}, canDeleteSpace: data.permissions?.canDeleteSpace || false,
canUploadCSR: data.permissions?.canUploadCSR || {}, canCreateFqdn: data.permissions?.canCreateFqdn || {},
canSignCSR: data.permissions?.canSignCSR || {}, 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) { } catch (err) {
console.error('Error fetching permissions:', err) console.error('Error fetching permissions:', err)
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
} finally { } finally {
setLoading(false) if (isInitial && isMountedRef.current) {
setLoading(false)
}
} }
}, [isAuthenticated, authFetch]) }, [isAuthenticated, authFetch])
// Initiales Laden der Permissions
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
fetchPermissions() fetchPermissions(true)
} else { } else {
setPermissions({ setPermissions({
isAdmin: false, isAdmin: false,
@@ -67,6 +87,69 @@ export const PermissionsProvider = ({ children }) => {
} }
}, [isAuthenticated, fetchPermissions]) }, [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 canCreateSpace = () => permissions.canCreateSpace
const canDeleteSpace = (spaceId) => permissions.canDeleteSpace const canDeleteSpace = (spaceId) => permissions.canDeleteSpace
const canCreateFqdn = (spaceId) => permissions.canCreateFqdn[spaceId] === true const canCreateFqdn = (spaceId) => permissions.canCreateFqdn[spaceId] === true
@@ -75,11 +158,18 @@ export const PermissionsProvider = ({ children }) => {
const canSignCSR = (spaceId) => permissions.canSignCSR[spaceId] === true const canSignCSR = (spaceId) => permissions.canSignCSR[spaceId] === true
const hasAccessToSpace = (spaceId) => permissions.accessibleSpaces.includes(spaceId) const hasAccessToSpace = (spaceId) => permissions.accessibleSpaces.includes(spaceId)
// refreshPermissions Funktion, die auch loading state setzt
const refreshPermissions = useCallback(async () => {
await fetchPermissions(true)
}, [fetchPermissions])
const value = { const value = {
permissions, permissions,
loading, loading,
refreshPermissions: fetchPermissions, refreshPermissions,
isAdmin: permissions.isAdmin, isAdmin: permissions.isAdmin,
hasFullAccess: permissions.hasFullAccess,
accessibleSpaces: permissions.accessibleSpaces,
canCreateSpace, canCreateSpace,
canDeleteSpace, canDeleteSpace,
canCreateFqdn, canCreateFqdn,

View File

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

View File

@@ -1,9 +1,12 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import ProvidersSection from '../components/ProvidersSection' import { usePermissions } from '../hooks/usePermissions'
const Home = () => { const Home = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const location = useLocation()
const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [loadingStats, setLoadingStats] = useState(true) const [loadingStats, setLoadingStats] = useState(true)
@@ -11,6 +14,19 @@ const Home = () => {
const intervalRef = useRef(null) const intervalRef = useRef(null)
const isMountedRef = useRef(true) 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 // Fetch stats function
const fetchStats = useCallback(async (isInitial = false) => { const fetchStats = useCallback(async (isInitial = false) => {
try { try {
@@ -188,7 +204,36 @@ const Home = () => {
Dies ist die Startseite der Certigo Addon Anwendung. Dies ist die Startseite der Certigo Addon Anwendung.
</p> </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 */} {/* Stats Dashboard */}
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6"> <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"> <div className="flex items-center justify-between mb-4">
@@ -306,9 +351,6 @@ const Home = () => {
<p className="text-slate-400">Lade Daten...</p> <p className="text-slate-400">Lade Daten...</p>
)} )}
</div> </div>
{/* SSL Certificate Providers */}
<ProvidersSection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions' import { usePermissions } from '../contexts/PermissionsContext'
const Permissions = () => { const Permissions = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../contexts/PermissionsContext' import { usePermissions } from '../hooks/usePermissions'
const Profile = () => { const Profile = () => {
const { authFetch, user } = useAuth() const { authFetch, user } = 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 { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions' import { usePermissions } from '../contexts/PermissionsContext'
const SpaceDetail = () => { const SpaceDetail = () => {
const { id } = useParams() const { id } = useParams()
@@ -232,18 +232,50 @@ const SpaceDetail = () => {
if (response.ok) { if (response.ok) {
const csr = await response.json() const csr = await response.json()
// Füge den neuen CSR zur History hinzu (nur wenn der Bereich bereits geöffnet ist)
if (showCSRDropdown[fqdn.id]) {
const newCsrWithFqdnId = { ...csr, fqdnId: fqdn.id }
setCsrHistory(prev => {
const filtered = prev.filter(csrItem => csrItem.fqdnId !== fqdn.id)
// Füge den neuen CSR am Anfang hinzu (neuester zuerst)
return [newCsrWithFqdnId, ...filtered]
})
}
setCsrData(csr) setCsrData(csr)
setSelectedFqdn(fqdn) setSelectedFqdn(fqdn)
// Lade die komplette CSR History neu, um den neuen CSR anzuzeigen
// WICHTIG: Warte auf die History bevor der Dropdown geöffnet wird
try {
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
if (historyResponse.ok) {
const history = await historyResponse.json()
// Stelle sicher, dass history ein Array ist
const historyArray = Array.isArray(history) ? history : []
// Füge fqdnId zu jedem CSR hinzu und stelle sicher dass sie immer gesetzt ist
// Auch wenn die API fqdnId bereits zurückgibt, überschreiben wir sie mit fqdn.id für Konsistenz
// Stelle sicher dass alle CSRs gültig sind und fqdnId gesetzt ist
const historyWithFqdnId = historyArray
.filter(csrItem => csrItem && csrItem.id) // Stelle sicher dass CSR gültig ist
.map(csrItem => ({
...csrItem,
fqdnId: String(fqdn.id) // Immer als String für konsistente Filterung
}))
setCsrHistory(prev => {
// Entferne alte CSRs für diesen FQDN und füge die neuen hinzu
// Verwende String-Vergleich für Robustheit
const filtered = prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId]
})
// Öffne den CSR History Dropdown NACH dem Laden der History
setShowCSRDropdown(prev => ({ ...prev, [fqdn.id]: true }))
} else {
setCsrHistory(prev => {
// Bei Fehler, entferne nur CSRs für diesen FQDN, behalte andere
return prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id))
})
}
} catch (err) {
console.error('Error fetching CSR history after upload:', err)
// Bei Fehler, entferne nur CSRs für diesen FQDN
setCsrHistory(prev => prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id)))
}
setShowCSRModal(true) setShowCSRModal(true)
// Aktualisiere die FQDN-Liste // Aktualisiere die FQDN-Liste
@@ -298,14 +330,23 @@ const SpaceDetail = () => {
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`) const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
if (historyResponse.ok) { if (historyResponse.ok) {
const history = await historyResponse.json() const history = await historyResponse.json()
setCsrHistory(Array.isArray(history) ? history : []) const historyArray = Array.isArray(history) ? history : []
// Stelle sicher dass fqdnId gesetzt ist für konsistente Filterung
const historyWithFqdnId = historyArray
.filter(csr => csr && csr.id)
.map(csr => ({ ...csr, fqdnId: String(fqdn.id) }))
setCsrHistory(prev => {
const filtered = prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId]
})
} else { } else {
setCsrHistory([]) setCsrHistory(prev => prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id)))
} }
} catch (err) { } catch (err) {
console.error('Error fetching CSR:', err) console.error('Error fetching CSR:', err)
setCsrData(null) setCsrData(null)
setCsrHistory([]) // Entferne nur CSRs für diesen FQDN, behalte andere
setCsrHistory(prev => prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id)))
} }
} }
@@ -319,7 +360,7 @@ const SpaceDetail = () => {
setSelectedFqdn(null) setSelectedFqdn(null)
setCsrData(null) setCsrData(null)
setCsrError('') setCsrError('')
setCsrHistory([]) // csrHistory NICHT zurücksetzen - bleibt für Dropdown-Anzeige erhalten
} }
const handleChange = (e) => { const handleChange = (e) => {
@@ -423,40 +464,64 @@ const SpaceDetail = () => {
} }
const handleViewCertificates = async (fqdn) => { const handleViewCertificates = async (fqdn) => {
if (!fqdn || !fqdn.id) {
console.error('Invalid FQDN provided to handleViewCertificates')
return
}
setSelectedFqdn(fqdn) setSelectedFqdn(fqdn)
setLoadingCertificates(true) setLoadingCertificates(true)
setCertificates([]) setCertificates([])
setShowCertificatesModal(true) // Öffne Modal sofort, auch wenn noch geladen wird
try { try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`) const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
if (response.ok) { if (response.ok) {
const certs = await response.json() try {
setCertificates(certs) 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 { } 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) { } catch (err) {
console.error('Error fetching certificates:', err) console.error('Error fetching certificates:', err)
setCertificates([])
} finally { } finally {
setLoadingCertificates(false) setLoadingCertificates(false)
setShowCertificatesModal(true)
} }
} }
const handleRefreshCertificate = async (cert) => { const handleRefreshCertificate = async (cert) => {
if (!cert || !cert.id || !selectedFqdn || !selectedFqdn.id) {
console.error('Invalid certificate or FQDN for refresh')
return
}
setRefreshingCertificate(cert.id) setRefreshingCertificate(cert.id)
try { try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, { const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, {
method: 'POST' method: 'POST'
}) })
if (response.ok) { if (response.ok) {
const result = await response.json() try {
// Aktualisiere Zertifikat in der Liste const result = await response.json()
setCertificates(prev => prev.map(c => // Aktualisiere Zertifikat in der Liste
c.id === cert.id setCertificates(prev => prev.map(c =>
? { ...c, certificatePEM: result.certificatePEM } c.id === cert.id
: c ? { ...c, certificatePEM: result.certificatePEM }
)) : c
))
} catch (parseErr) {
console.error('Error parsing refresh response:', parseErr)
}
} }
} catch (err) { } catch (err) {
console.error('Error refreshing certificate:', err) console.error('Error refreshing certificate:', err)
@@ -798,11 +863,12 @@ const SpaceDetail = () => {
if (response.ok) { if (response.ok) {
const history = await response.json() const history = await response.json()
// Speichere History mit FQDN-ID als Key // Speichere History mit FQDN-ID als Key
const historyWithFqdnId = Array.isArray(history) const historyArray = Array.isArray(history) ? history : []
? history.map(csr => ({ ...csr, fqdnId: fqdn.id })) const historyWithFqdnId = historyArray
: [] .filter(csr => csr && csr.id)
.map(csr => ({ ...csr, fqdnId: String(fqdn.id) }))
setCsrHistory(prev => { setCsrHistory(prev => {
const filtered = prev.filter(csr => csr.fqdnId !== fqdn.id) const filtered = prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId] return [...filtered, ...historyWithFqdnId]
}) })
} }
@@ -866,12 +932,13 @@ const SpaceDetail = () => {
<div className="border-t border-slate-600/50 bg-slate-800/50 p-4"> <div className="border-t border-slate-600/50 bg-slate-800/50 p-4">
<h5 className="text-sm font-semibold text-slate-300 mb-3">CSR History</h5> <h5 className="text-sm font-semibold text-slate-300 mb-3">CSR History</h5>
{(() => { {(() => {
// Filtere CSRs für diesen FQDN - verwende String-Vergleich für Robustheit
const fqdnHistory = csrHistory const fqdnHistory = csrHistory
.filter(csr => csr.fqdnId === fqdn.id) .filter(csr => csr && String(csr.fqdnId) === String(fqdn.id))
.sort((a, b) => { .sort((a, b) => {
// Sortiere nach created_at, neueste zuerst // Sortiere nach created_at, neueste zuerst
const dateA = new Date(a.createdAt).getTime() const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = new Date(b.createdAt).getTime() const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA return dateB - dateA
}) })
return fqdnHistory.length > 0 ? ( return fqdnHistory.length > 0 ? (
@@ -1478,10 +1545,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="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="p-6">
<div className="flex justify-between items-center mb-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 <button
onClick={closeCertificatesModal} onClick={closeCertificatesModal}
className="text-slate-400 hover:text-white transition-colors" 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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -1501,64 +1571,80 @@ const SpaceDetail = () => {
{certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden {certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden
</p> </p>
</div> </div>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => {
<div key={cert.id} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600"> // Sicherstellen, dass cert ein gültiges Objekt ist
<div className="flex justify-between items-start mb-3"> if (!cert || !cert.id) {
<div className="flex-1"> return null
<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} const certId = cert.id || 'unknown'
</span> const certCertificateId = cert.certificateId || 'N/A'
<h4 className="text-white font-semibold">CA-Zertifikat-ID: {cert.certificateId}</h4> const certCreatedAt = cert.createdAt ? new Date(cert.createdAt) : null
</div> const certStatus = cert.status || 'unknown'
<div className="space-y-1"> const certProviderId = cert.providerId
<p className="text-slate-400 text-xs"> const certPEM = cert.certificatePEM
<span className="font-semibold text-slate-300">Interne UID:</span>{' '}
<span className="font-mono text-xs">{cert.id}</span> return (
</p> <div key={certId} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600">
<p className="text-slate-400 text-sm"> <div className="flex justify-between items-start mb-3">
<span className="font-semibold text-slate-300">Erstellt:</span>{' '} <div className="flex-1">
{new Date(cert.createdAt).toLocaleString('de-DE')} <div className="flex items-center gap-2 mb-2">
</p> <span className="text-xs font-semibold text-purple-400 bg-purple-500/20 px-2 py-1 rounded">
<p className="text-slate-400 text-sm"> #{certificates.length - index}
<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}
</span> </span>
</p> <h4 className="text-white font-semibold">CA-Zertifikat-ID: {certCertificateId}</h4>
{cert.providerId && ( </div>
<p className="text-slate-400 text-sm"> <div className="space-y-1">
<span className="font-semibold text-slate-300">Provider:</span>{' '} <p className="text-slate-400 text-xs">
{cert.providerId} <span className="font-semibold text-slate-300">Interne UID:</span>{' '}
<span className="font-mono text-xs">{certId}</span>
</p> </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> </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> </div>
<button {certPEM && (
onClick={() => handleRefreshCertificate(cert)} <div className="mt-3">
disabled={refreshingCertificate === cert.id} <h5 className="text-sm font-semibold text-slate-300 mb-2">Zertifikat (PEM):</h5>
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" <pre className="text-xs text-slate-200 bg-slate-900/50 p-3 rounded overflow-auto max-h-60 font-mono">
title="Zertifikat von CA abrufen" {certPEM}
> </pre>
{refreshingCertificate === cert.id ? 'Aktualisiere...' : 'Aktualisieren'} </div>
</button> )}
</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>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions' import { usePermissions } from '../contexts/PermissionsContext'
const Spaces = () => { const Spaces = () => {
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions' import { usePermissions } from '../contexts/PermissionsContext'
const Users = () => { const Users = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()