Compare commits
17 Commits
5523c6ff06
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a1168abf0 | |||
| 688b277b5d | |||
| 145dfd3d7c | |||
| ec1e0da9d5 | |||
| 6623b07502 | |||
| 2f2be739f2 | |||
| d1e9c2433c | |||
| f0c23cad35 | |||
| 39148bbb56 | |||
| e96fa8f367 | |||
| 97163becfa | |||
| 16043e2577 | |||
| e3a2ccb82d | |||
| dbb8049c7e | |||
| 24d97f6057 | |||
| d23bfa0376 | |||
| 0d17fda341 |
346
.gitignore
vendored
346
.gitignore
vendored
@@ -1,29 +1,361 @@
|
||||
# ============================================
|
||||
# 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
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
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
|
||||
# ============================================
|
||||
|
||||
# SQLite databases
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backend/spaces.db
|
||||
backend/*.db
|
||||
backend/**/*.db
|
||||
backend/**/*.db-shm
|
||||
backend/**/*.db-wal
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
# Test databases
|
||||
backend/testing/**/*.db
|
||||
backend/testing/**/*.db-shm
|
||||
backend/testing/**/*.db-wal
|
||||
|
||||
# IDE
|
||||
# Database backups
|
||||
*.sql.backup
|
||||
*.db.backup
|
||||
|
||||
# ============================================
|
||||
# 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
|
||||
backend/logs/
|
||||
backend/logs/**
|
||||
*.log.*
|
||||
|
||||
# ============================================
|
||||
# IDE & Editors
|
||||
# ============================================
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# IntelliJ IDEA / WebStorm
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
out/
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vim/
|
||||
|
||||
# OS
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# ============================================
|
||||
# OS Files
|
||||
# ============================================
|
||||
|
||||
# macOS
|
||||
.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/
|
||||
|
||||
# Testing directory (keep structure, ignore test databases)
|
||||
backend/testing/scripts/*.db
|
||||
backend/testing/scripts/*.db-shm
|
||||
backend/testing/scripts/*.db-wal
|
||||
|
||||
# Keep directory structure but ignore contents
|
||||
!backend/uploads/.gitkeep
|
||||
!backend/config/providers/.gitkeep
|
||||
!backend/testing/.gitkeep
|
||||
!backend/testing/README.md
|
||||
!backend/testing/scripts/*.go
|
||||
|
||||
1448
backend/acme_client.go
Normal file
1448
backend/acme_client.go
Normal file
File diff suppressed because it is too large
Load Diff
57
backend/acme_client_context.go
Normal file
57
backend/acme_client_context.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"certigo-addon-backend/providers"
|
||||
)
|
||||
|
||||
// ACMEClientContext enthält den Kontext für ACME-Operationen
|
||||
type ACMEClientContext struct {
|
||||
Provider providers.ACMEProvider
|
||||
Directory *ACMEDirectory
|
||||
DirectoryURL string
|
||||
NewAccountURL string
|
||||
NewOrderURL string
|
||||
NewNonceURL string
|
||||
}
|
||||
|
||||
// NewACMEClientContext erstellt einen neuen ACME-Client-Kontext
|
||||
func NewACMEClientContext(providerID string) (*ACMEClientContext, error) {
|
||||
acmeManager := providers.GetACMEManager()
|
||||
provider, exists := acmeManager.GetACMEProvider(providerID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID)
|
||||
}
|
||||
|
||||
config, err := acmeManager.GetACMEProviderConfig(providerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Laden der Provider-Konfiguration: %v", err)
|
||||
}
|
||||
|
||||
if !config.Enabled {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' ist nicht aktiviert", providerID)
|
||||
}
|
||||
|
||||
// Validiere Konfiguration
|
||||
if err := provider.ValidateConfig(config.Settings); err != nil {
|
||||
return nil, fmt.Errorf("ungültige Provider-Konfiguration: %v", err)
|
||||
}
|
||||
|
||||
directoryURL := provider.GetDirectoryURL()
|
||||
|
||||
// Hole Directory-Endpunkte
|
||||
directory, err := getACMEDirectory(directoryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory: %v", err)
|
||||
}
|
||||
|
||||
return &ACMEClientContext{
|
||||
Provider: provider,
|
||||
Directory: directory,
|
||||
DirectoryURL: directoryURL,
|
||||
NewAccountURL: directory.NewAccount,
|
||||
NewOrderURL: directory.NewOrder,
|
||||
NewNonceURL: directory.NewNonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
65
backend/cert_logger.go
Normal file
65
backend/cert_logger.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
var certLogger *log.Logger
|
||||
var certLogFile *os.File
|
||||
|
||||
// initCertLogger initialisiert das Logging-System für Zertifikatsanfragen
|
||||
func initCertLogger() error {
|
||||
logDir := "logs"
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return fmt.Errorf("fehler beim Erstellen des Log-Verzeichnisses: %v", err)
|
||||
}
|
||||
|
||||
logFile := filepath.Join(logDir, fmt.Sprintf("cert-requests-%s.log", time.Now().Format("2006-01-02")))
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Öffnen der Log-Datei: %v", err)
|
||||
}
|
||||
|
||||
certLogFile = file
|
||||
certLogger = log.New(file, "", log.LstdFlags)
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateTraceID generiert eine eindeutige TraceID für einen Vorgang
|
||||
func generateTraceID() string {
|
||||
bytes := make([]byte, 8)
|
||||
rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// logCertStatus schreibt einen Status-Eintrag in die Log-Datei
|
||||
// traceID: Eindeutige ID für den Vorgang
|
||||
// step: Name des Schritts (z.B. "DNS_PRÜFUNG", "REGISTER_AUFRUF", "ACCOUNT_ERSTELLUNG", "ORDER_ERSTELLUNG", "CHALLENGE_VALIDIERUNG", "ZERTIFIKAT_ERSTELLT")
|
||||
// status: "OK" oder "FAILED"
|
||||
// message: Fehlermeldung bei FAILED, leer bei OK
|
||||
func logCertStatus(traceID, fqdnID, step, status, message string) {
|
||||
if certLogger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
if status == "OK" {
|
||||
certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=OK", timestamp, traceID, fqdnID, step)
|
||||
} else {
|
||||
certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=FAILED ERROR=%s", timestamp, traceID, fqdnID, step, message)
|
||||
}
|
||||
}
|
||||
|
||||
// closeCertLogger schließt die Log-Datei
|
||||
func closeCertLogger() {
|
||||
if certLogFile != nil {
|
||||
certLogFile.Close()
|
||||
}
|
||||
}
|
||||
|
||||
160
backend/cert_parser.go
Normal file
160
backend/cert_parser.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParseCertificateExtrakt Ablaufdatum und CA-Status aus einem PEM-Zertifikat
|
||||
// Gibt zurück: expiresAt, isIntermediate, error
|
||||
func ParseCertificate(certPEM string) (time.Time, bool, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return time.Time{}, false, fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := cert.NotAfter
|
||||
// Ein Zertifikat ist Intermediate wenn IsCA=true ist
|
||||
isIntermediate := cert.IsCA
|
||||
|
||||
return expiresAt, isIntermediate, nil
|
||||
}
|
||||
|
||||
// SplitCertificateChain trennt eine PEM-Zertifikatskette in einzelne Zertifikate
|
||||
// Gibt zurück: leafCert (PEM), intermediateCert (PEM), error
|
||||
func SplitCertificateChain(certChainPEM string) (string, string, error) {
|
||||
var leafCert string
|
||||
var intermediateCert string
|
||||
|
||||
// Dekodiere alle PEM-Blöcke aus der Kette
|
||||
var blocks []*pem.Block
|
||||
rest := []byte(certChainPEM)
|
||||
for {
|
||||
block, remaining := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
blocks = append(blocks, block)
|
||||
}
|
||||
rest = remaining
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return "", "", fmt.Errorf("keine Zertifikate in der Kette gefunden")
|
||||
}
|
||||
|
||||
// Parse jedes Zertifikat und trenne nach IsCA
|
||||
for _, block := range blocks {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
continue // Überspringe ungültige Zertifikate
|
||||
}
|
||||
|
||||
// Encode zurück zu PEM
|
||||
certPEM := string(pem.EncodeToMemory(block))
|
||||
|
||||
if cert.IsCA {
|
||||
// Intermediate CA
|
||||
if intermediateCert != "" {
|
||||
// Wenn bereits ein Intermediate vorhanden ist, hänge es an (kann mehrere geben)
|
||||
intermediateCert += "\n" + certPEM
|
||||
} else {
|
||||
intermediateCert = certPEM
|
||||
}
|
||||
} else {
|
||||
// Leaf Certificate
|
||||
if leafCert != "" {
|
||||
// Wenn bereits ein Leaf vorhanden ist, verwende das erste (sollte nur eines geben)
|
||||
continue
|
||||
}
|
||||
leafCert = certPEM
|
||||
}
|
||||
}
|
||||
|
||||
return leafCert, intermediateCert, nil
|
||||
}
|
||||
|
||||
// GetCertificateIssuer extrahiert den Issuer-Namen aus einem PEM-Zertifikat
|
||||
// Gibt zurück: issuerName (string), error
|
||||
func GetCertificateIssuer(certPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
|
||||
}
|
||||
|
||||
return cert.Issuer.String(), nil
|
||||
}
|
||||
|
||||
// GetProviderNameFromIssuer bestimmt den Provider-Namen basierend auf dem Issuer
|
||||
func GetProviderNameFromIssuer(issuer string) string {
|
||||
issuerLower := fmt.Sprintf("%v", issuer)
|
||||
if strings.Contains(issuerLower, "Let's Encrypt") || strings.Contains(issuerLower, "letsencrypt") {
|
||||
return "Let's Encrypt"
|
||||
}
|
||||
if strings.Contains(issuerLower, "DigiCert") {
|
||||
return "DigiCert"
|
||||
}
|
||||
if strings.Contains(issuerLower, "GlobalSign") {
|
||||
return "GlobalSign"
|
||||
}
|
||||
if strings.Contains(issuerLower, "Sectigo") {
|
||||
return "Sectigo"
|
||||
}
|
||||
if strings.Contains(issuerLower, "GoDaddy") {
|
||||
return "GoDaddy"
|
||||
}
|
||||
// Fallback: Gib den Issuer-Namen zurück
|
||||
return issuer
|
||||
}
|
||||
|
||||
// CheckExistingValidCertificate prüft ob bereits ein gültiges Zertifikat für einen FQDN existiert
|
||||
// Gibt zurück: exists (bool), expiresAt (time.Time), error
|
||||
func CheckExistingValidCertificate(fqdnID, spaceID string) (bool, time.Time, error) {
|
||||
var certPEM string
|
||||
var expiresAtStr sql.NullString
|
||||
err := db.QueryRow(`
|
||||
SELECT certificate_pem, expires_at
|
||||
FROM certificates
|
||||
WHERE fqdn_id = ? AND space_id = ? AND status = 'issued'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, fqdnID, spaceID).Scan(&certPEM, &expiresAtStr)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
return false, time.Time{}, err
|
||||
}
|
||||
|
||||
// Wenn expires_at bereits in DB vorhanden ist, verwende es
|
||||
if expiresAtStr.Valid && expiresAtStr.String != "" {
|
||||
expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr.String)
|
||||
if err == nil {
|
||||
return true, expiresAt, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Sonst parse das Zertifikat
|
||||
expiresAt, _, err := ParseCertificate(certPEM)
|
||||
if err != nil {
|
||||
return true, time.Time{}, err
|
||||
}
|
||||
|
||||
return true, expiresAt, nil
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"acme_ready": false,
|
||||
"settings": {
|
||||
"password": "test",
|
||||
"username": "test"
|
||||
|
||||
7
backend/config/providers/certigo-acmeproxy.json
Normal file
7
backend/config/providers/certigo-acmeproxy.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"acme_ready": true,
|
||||
"settings": {
|
||||
"baseURL": "http://openmailserver.de:8080"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"acme_ready": false,
|
||||
"settings": {}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"acme_ready": false,
|
||||
"settings": {}
|
||||
}
|
||||
5
backend/config/providers/letsencrypt-production.json
Normal file
5
backend/config/providers/letsencrypt-production.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
5
backend/config/providers/letsencrypt-staging.json
Normal file
5
backend/config/providers/letsencrypt-staging.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ go 1.24.0
|
||||
toolchain go1.24.10
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
golang.org/x/crypto v0.45.0
|
||||
)
|
||||
|
||||
require golang.org/x/crypto v0.45.0 // indirect
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
|
||||
3000
backend/main.go
3000
backend/main.go
File diff suppressed because it is too large
Load Diff
BIN
backend/myapp
BIN
backend/myapp
Binary file not shown.
1292
backend/openapi.yaml
1292
backend/openapi.yaml
File diff suppressed because it is too large
Load Diff
182
backend/providers/acme_provider.go
Normal file
182
backend/providers/acme_provider.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ACMEProvider Interface für ACME-basierte Certificate Authorities
|
||||
type ACMEProvider interface {
|
||||
// GetName gibt den Namen des ACME-Providers zurück
|
||||
GetName() string
|
||||
// GetDisplayName gibt den Anzeigenamen zurück
|
||||
GetDisplayName() string
|
||||
// GetDescription gibt eine Beschreibung zurück
|
||||
GetDescription() string
|
||||
// GetDirectoryURL gibt die ACME Directory URL zurück
|
||||
GetDirectoryURL() string
|
||||
// GetRenewalInfoURL gibt die RenewalInfo API URL zurück (optional)
|
||||
GetRenewalInfoURL() string
|
||||
// ValidateConfig validiert die Konfiguration
|
||||
ValidateConfig(settings map[string]interface{}) error
|
||||
// TestConnection testet die Verbindung zum ACME-Server
|
||||
TestConnection(settings map[string]interface{}) error
|
||||
// GetRequiredSettings gibt die erforderlichen Einstellungen zurück
|
||||
GetRequiredSettings() []SettingField
|
||||
}
|
||||
|
||||
// ACMEProviderConfig enthält die Konfiguration eines ACME-Providers
|
||||
type ACMEProviderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
}
|
||||
|
||||
// ACMEProviderManager verwaltet alle ACME-Provider
|
||||
type ACMEProviderManager struct {
|
||||
providers map[string]ACMEProvider
|
||||
configs map[string]*ACMEProviderConfig
|
||||
configDir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var acmeManager *ACMEProviderManager
|
||||
var acmeOnce sync.Once
|
||||
|
||||
// GetACMEManager gibt die Singleton-Instanz des ACMEProviderManagers zurück
|
||||
func GetACMEManager() *ACMEProviderManager {
|
||||
acmeOnce.Do(func() {
|
||||
acmeManager = &ACMEProviderManager{
|
||||
providers: make(map[string]ACMEProvider),
|
||||
configs: make(map[string]*ACMEProviderConfig),
|
||||
configDir: "./config/providers",
|
||||
}
|
||||
acmeManager.loadAllConfigs()
|
||||
})
|
||||
return acmeManager
|
||||
}
|
||||
|
||||
// RegisterACMEProvider registriert einen neuen ACME-Provider
|
||||
func (pm *ACMEProviderManager) RegisterACMEProvider(provider ACMEProvider) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
providerID := provider.GetName()
|
||||
pm.providers[providerID] = provider
|
||||
|
||||
// Lade Konfiguration falls vorhanden
|
||||
if pm.configs[providerID] == nil {
|
||||
pm.configs[providerID] = &ACMEProviderConfig{
|
||||
Enabled: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetACMEProvider gibt einen ACME-Provider zurück
|
||||
func (pm *ACMEProviderManager) GetACMEProvider(id string) (ACMEProvider, bool) {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
provider, exists := pm.providers[id]
|
||||
return provider, exists
|
||||
}
|
||||
|
||||
// GetAllACMEProviders gibt alle registrierten ACME-Provider zurück
|
||||
func (pm *ACMEProviderManager) GetAllACMEProviders() map[string]ACMEProvider {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
result := make(map[string]ACMEProvider)
|
||||
for id, provider := range pm.providers {
|
||||
result[id] = provider
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetACMEProviderConfig gibt die Konfiguration eines ACME-Providers zurück
|
||||
func (pm *ACMEProviderManager) GetACMEProviderConfig(id string) (*ACMEProviderConfig, error) {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
config, exists := pm.configs[id]
|
||||
if !exists {
|
||||
return &ACMEProviderConfig{
|
||||
Enabled: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SetACMEProviderEnabled aktiviert/deaktiviert einen ACME-Provider
|
||||
func (pm *ACMEProviderManager) SetACMEProviderEnabled(id string, enabled bool) error {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
if pm.configs[id] == nil {
|
||||
pm.configs[id] = &ACMEProviderConfig{
|
||||
Enabled: enabled,
|
||||
Settings: make(map[string]interface{}),
|
||||
}
|
||||
} else {
|
||||
pm.configs[id].Enabled = enabled
|
||||
}
|
||||
|
||||
return pm.saveConfig(id, pm.configs[id])
|
||||
}
|
||||
|
||||
// loadAllConfigs lädt alle Konfigurationsdateien
|
||||
func (pm *ACMEProviderManager) loadAllConfigs() {
|
||||
// Stelle sicher, dass das Verzeichnis existiert
|
||||
os.MkdirAll(pm.configDir, 0755)
|
||||
|
||||
// Lade alle JSON-Dateien im Konfigurationsverzeichnis
|
||||
files, err := filepath.Glob(filepath.Join(pm.configDir, "*.json"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
id := filepath.Base(file[:len(file)-5]) // Entferne .json
|
||||
// Nur ACME-Provider-Konfigurationen laden (beginnen mit "letsencrypt")
|
||||
if id == "letsencrypt-production" || id == "letsencrypt-staging" {
|
||||
config, err := pm.loadConfig(id)
|
||||
if err == nil {
|
||||
pm.configs[id] = config
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadConfig lädt eine Konfigurationsdatei
|
||||
func (pm *ACMEProviderManager) loadConfig(id string) (*ACMEProviderConfig, error) {
|
||||
filePath := filepath.Join(pm.configDir, id+".json")
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config ACMEProviderConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// saveConfig speichert eine Konfiguration in eine Datei
|
||||
func (pm *ACMEProviderManager) saveConfig(id string, config *ACMEProviderConfig) error {
|
||||
// Stelle sicher, dass das Verzeichnis existiert
|
||||
os.MkdirAll(pm.configDir, 0755)
|
||||
|
||||
filePath := filepath.Join(pm.configDir, id+".json")
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, data, 0644)
|
||||
}
|
||||
|
||||
234
backend/providers/certigo-acmeproxy.go
Normal file
234
backend/providers/certigo-acmeproxy.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CertigoACMEProxyProvider ist der Provider für certigo-acmeproxy
|
||||
type CertigoACMEProxyProvider struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func NewCertigoACMEProxyProvider() *CertigoACMEProxyProvider {
|
||||
return &CertigoACMEProxyProvider{}
|
||||
}
|
||||
|
||||
func (p *CertigoACMEProxyProvider) GetName() string {
|
||||
return "certigo-acmeproxy"
|
||||
}
|
||||
|
||||
func (p *CertigoACMEProxyProvider) GetDisplayName() string {
|
||||
return "Certigo ACME Proxy"
|
||||
}
|
||||
|
||||
func (p *CertigoACMEProxyProvider) GetDescription() string {
|
||||
return "ACME DNS-01 Challenge Responder für Let's Encrypt und andere ACME CAs"
|
||||
}
|
||||
|
||||
func (p *CertigoACMEProxyProvider) ValidateConfig(settings map[string]interface{}) error {
|
||||
baseURL, ok := settings["baseURL"].(string)
|
||||
if !ok || strings.TrimSpace(baseURL) == "" {
|
||||
return fmt.Errorf("baseURL ist erforderlich")
|
||||
}
|
||||
|
||||
// Entferne trailing slash falls vorhanden
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
// Validiere URL-Format
|
||||
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||
return fmt.Errorf("baseURL muss mit http:// oder https:// beginnen")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CertigoACMEProxyProvider) TestConnection(settings map[string]interface{}) error {
|
||||
// Validiere zuerst die Konfiguration
|
||||
if err := p.ValidateConfig(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL, _ := settings["baseURL"].(string)
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
// Teste Verbindung über Health Check
|
||||
url := fmt.Sprintf("%s/health", baseURL)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("acme-proxy nicht erreichbar: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("acme-proxy antwortet mit Status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Prüfe Response Body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Lesen der Health-Check-Response: %v", err)
|
||||
}
|
||||
|
||||
var healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &healthResponse); err != nil {
|
||||
return fmt.Errorf("ungültige Health-Check-Response: %v", err)
|
||||
}
|
||||
|
||||
if healthResponse.Status != "ok" {
|
||||
return fmt.Errorf("acme-proxy meldet Status: %s", healthResponse.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRequiredSettings gibt die erforderlichen Einstellungen zurück
|
||||
func (p *CertigoACMEProxyProvider) GetRequiredSettings() []SettingField {
|
||||
return []SettingField{
|
||||
{
|
||||
Name: "baseURL",
|
||||
Label: "Base URL",
|
||||
Type: "text",
|
||||
Required: true,
|
||||
Description: "Base URL des certigo-acmeproxy Services (z.B. http://localhost:8080)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterChallengeDomain registriert eine neue Challenge-Domain beim ACME Proxy
|
||||
func (p *CertigoACMEProxyProvider) RegisterChallengeDomain(settings map[string]interface{}) (*ChallengeDomainResponse, error) {
|
||||
if err := p.ValidateConfig(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseURL, _ := settings["baseURL"].(string)
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
url := fmt.Sprintf("%s/register", baseURL)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var registerResponse ChallengeDomainResponse
|
||||
if err := json.Unmarshal(body, ®isterResponse); err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err)
|
||||
}
|
||||
|
||||
return ®isterResponse, nil
|
||||
}
|
||||
|
||||
// UpdateChallengeToken setzt oder aktualisiert den ACME Challenge Token
|
||||
func (p *CertigoACMEProxyProvider) UpdateChallengeToken(username, password, token string, settings map[string]interface{}) error {
|
||||
if err := p.ValidateConfig(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL, _ := settings["baseURL"].(string)
|
||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
|
||||
url := fmt.Sprintf("%s/update", baseURL)
|
||||
|
||||
requestBody := map[string]string{
|
||||
"txt": token,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Erstellen des Request-Body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Basic Authentication
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Senden des Requests: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Lesen der Response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return fmt.Errorf("ungültige Authentifizierung")
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
return fmt.Errorf("ungültige Anfrage: %s", string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChallengeDomainResponse enthält die Response von /register
|
||||
type ChallengeDomainResponse struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Fulldomain string `json:"fulldomain"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
}
|
||||
|
||||
// SignCSR signiert einen CSR (für ACME nicht direkt verwendet, aber Interface erfordert es)
|
||||
func (p *CertigoACMEProxyProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) {
|
||||
return nil, fmt.Errorf("certigo-acmeproxy unterstützt keine direkte CSR-Signierung. Verwenden Sie ACME für Zertifikatsanfragen.")
|
||||
}
|
||||
|
||||
// GetCertificate ruft ein Zertifikat ab (für ACME nicht direkt verwendet, aber Interface erfordert es)
|
||||
func (p *CertigoACMEProxyProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) {
|
||||
return "", fmt.Errorf("certigo-acmeproxy unterstützt keinen direkten Zertifikat-Abruf. Verwenden Sie ACME für Zertifikatsanfragen.")
|
||||
}
|
||||
|
||||
106
backend/providers/letsencrypt.go
Normal file
106
backend/providers/letsencrypt.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LetsEncryptProvider ist der Provider für Let's Encrypt
|
||||
type LetsEncryptProvider struct {
|
||||
environment string // "production" oder "staging"
|
||||
}
|
||||
|
||||
// NewLetsEncryptProvider erstellt einen neuen Let's Encrypt Provider
|
||||
func NewLetsEncryptProvider(environment string) *LetsEncryptProvider {
|
||||
if environment != "staging" && environment != "production" {
|
||||
environment = "production"
|
||||
}
|
||||
return &LetsEncryptProvider{
|
||||
environment: environment,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetName() string {
|
||||
if p.environment == "staging" {
|
||||
return "letsencrypt-staging"
|
||||
}
|
||||
return "letsencrypt-production"
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetDisplayName() string {
|
||||
if p.environment == "staging" {
|
||||
return "Let's Encrypt (Staging)"
|
||||
}
|
||||
return "Let's Encrypt (Production)"
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetDescription() string {
|
||||
if p.environment == "staging" {
|
||||
return "Let's Encrypt Staging Environment für Tests"
|
||||
}
|
||||
return "Let's Encrypt Production Certificate Authority"
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetDirectoryURL() string {
|
||||
if p.environment == "staging" {
|
||||
return "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
}
|
||||
return "https://acme-v02.api.letsencrypt.org/directory"
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetRenewalInfoURL() string {
|
||||
if p.environment == "staging" {
|
||||
return "https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info"
|
||||
}
|
||||
return "https://acme-v02.api.letsencrypt.org/acme/renewal-info"
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) ValidateConfig(settings map[string]interface{}) error {
|
||||
// Let's Encrypt benötigt keine zusätzliche Konfiguration
|
||||
// Die Directory URL wird automatisch basierend auf der Environment gesetzt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) TestConnection(settings map[string]interface{}) error {
|
||||
// Teste Verbindung zum ACME Directory
|
||||
directoryURL := p.GetDirectoryURL()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(directoryURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME Directory nicht erreichbar: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("ACME Directory antwortet mit Status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Prüfe ob es ein gültiges ACME Directory ist
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Lesen der Directory-Response: %v", err)
|
||||
}
|
||||
|
||||
// Einfache Validierung: Prüfe ob "newAccount" oder "newNonce" im Body enthalten ist
|
||||
bodyStr := string(body)
|
||||
if !strings.Contains(bodyStr, "newAccount") && !strings.Contains(bodyStr, "newNonce") {
|
||||
return fmt.Errorf("ungültige ACME Directory Response")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LetsEncryptProvider) GetRequiredSettings() []SettingField {
|
||||
// Let's Encrypt benötigt keine zusätzlichen Einstellungen
|
||||
// Die Directory URL wird automatisch basierend auf der Environment gesetzt
|
||||
return []SettingField{}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ import (
|
||||
|
||||
// ProviderConfig enthält die Konfiguration eines Providers
|
||||
type ProviderConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AcmeReady bool `json:"acme_ready"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
}
|
||||
|
||||
// SignCSRResult enthält das Ergebnis einer CSR-Signierung
|
||||
@@ -77,8 +78,9 @@ func (pm *ProviderManager) RegisterProvider(provider Provider) {
|
||||
// Lade Konfiguration falls vorhanden
|
||||
if pm.configs[providerID] == nil {
|
||||
pm.configs[providerID] = &ProviderConfig{
|
||||
Enabled: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
Enabled: false,
|
||||
AcmeReady: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,8 +112,9 @@ func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error)
|
||||
config, exists := pm.configs[id]
|
||||
if !exists {
|
||||
return &ProviderConfig{
|
||||
Enabled: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
Enabled: false,
|
||||
AcmeReady: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
}, nil
|
||||
}
|
||||
return config, nil
|
||||
@@ -145,8 +148,9 @@ func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error {
|
||||
|
||||
if pm.configs[id] == nil {
|
||||
pm.configs[id] = &ProviderConfig{
|
||||
Enabled: enabled,
|
||||
Settings: make(map[string]interface{}),
|
||||
Enabled: enabled,
|
||||
AcmeReady: false,
|
||||
Settings: make(map[string]interface{}),
|
||||
}
|
||||
} else {
|
||||
pm.configs[id].Enabled = enabled
|
||||
|
||||
@@ -17,6 +17,7 @@ type ProviderInfo struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
AcmeReady bool `json:"acme_ready"`
|
||||
Settings []SettingField `json:"settings"`
|
||||
}
|
||||
|
||||
|
||||
207
backend/renewal_info.go
Normal file
207
backend/renewal_info.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"certigo-addon-backend/providers"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RenewalInfoResponse enthält die Antwort von Let's Encrypt RenewalInfo API
|
||||
type RenewalInfoResponse struct {
|
||||
SuggestedWindow struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
} `json:"suggestedWindow"`
|
||||
}
|
||||
|
||||
// CalculateCertID berechnet die CertID für Let's Encrypt RenewalInfo API
|
||||
// Die CertID setzt sich aus Authority Key Identifier (AKI) und Serial Number zusammen
|
||||
// Format: base64url(AKI).base64url(SerialNumber)
|
||||
func CalculateCertID(certPEM string) (string, error) {
|
||||
// Parse Zertifikat
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
|
||||
}
|
||||
|
||||
// Verwende AuthorityKeyId direkt aus dem Zertifikat
|
||||
if len(cert.AuthorityKeyId) == 0 {
|
||||
return "", fmt.Errorf("Authority Key Identifier nicht im Zertifikat gefunden")
|
||||
}
|
||||
|
||||
// Encoding Referenz: base64.RawURLEncoding entfernt das Padding (=) automatisch
|
||||
encoder := base64.RawURLEncoding
|
||||
|
||||
// Teil 1: AKI
|
||||
akiPart := encoder.EncodeToString(cert.AuthorityKeyId)
|
||||
|
||||
// Teil 2: Serial Number
|
||||
// WICHTIG: .Bytes() nutzen, NICHT .String() (was dezimal wäre) oder Hex!
|
||||
serialPart := encoder.EncodeToString(cert.SerialNumber.Bytes())
|
||||
|
||||
// Resultat: AKI.SerialNumber mit Punkt getrennt
|
||||
certID := fmt.Sprintf("%s.%s", akiPart, serialPart)
|
||||
|
||||
return certID, nil
|
||||
}
|
||||
|
||||
// FetchRenewalInfo ruft die RenewalInfo von einem ACME-Server ab
|
||||
func FetchRenewalInfo(providerID string, certID string) (*RenewalInfoResponse, error) {
|
||||
// Hole ACME-Provider
|
||||
acmeManager := providers.GetACMEManager()
|
||||
provider, exists := acmeManager.GetACMEProvider(providerID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID)
|
||||
}
|
||||
|
||||
// Hole RenewalInfo URL vom Provider
|
||||
renewalInfoURL := provider.GetRenewalInfoURL()
|
||||
if renewalInfoURL == "" {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' unterstützt keine RenewalInfo API", providerID)
|
||||
}
|
||||
|
||||
// Erstelle URL mit CertID
|
||||
url := fmt.Sprintf("%s/%s", renewalInfoURL, certID)
|
||||
|
||||
// HTTP Request
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Prüfe ob es ein Staging-Zertifikat ist (404 mit spezifischer Fehlermeldung)
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
bodyStr := string(body)
|
||||
if strings.Contains(bodyStr, "Authority Key Identifier that did not match a known issuer") {
|
||||
return nil, fmt.Errorf("RenewalInfo nicht verfügbar für Staging-Zertifikate (Status %d)", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("RenewalInfo API Fehler (Status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var renewalInfo RenewalInfoResponse
|
||||
if err := json.Unmarshal(body, &renewalInfo); err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err)
|
||||
}
|
||||
|
||||
return &renewalInfo, nil
|
||||
}
|
||||
|
||||
// ProcessRenewalInfoForCertificate verarbeitet RenewalInfo für ein Zertifikat
|
||||
// Trennt die Zertifikatskette, berechnet CertID für Leaf, ruft RenewalInfo ab und erstellt Queue-Eintrag
|
||||
func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID string, spaceID string, renewalEnabled bool) error {
|
||||
if !renewalEnabled {
|
||||
log.Printf("RenewalInfo wird übersprungen - renewal_enabled ist für FQDN %s deaktiviert", fqdnID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trenne Zertifikatskette in Leaf und Intermediate
|
||||
leafPEM, _, err := SplitCertificateChain(certPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Trennen der Zertifikatskette: %v", err)
|
||||
}
|
||||
|
||||
if leafPEM == "" {
|
||||
return fmt.Errorf("kein Leaf-Zertifikat in der Kette gefunden")
|
||||
}
|
||||
|
||||
// Berechne CertID für Leaf-Zertifikat
|
||||
certIDBase64, err := CalculateCertID(leafPEM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Berechnen der CertID: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("CertID berechnet: %s für Zertifikat %s", certIDBase64, certID)
|
||||
|
||||
// Speichere CertID in DB
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(ctx, "UPDATE certificates SET cert_id_base64 = ? WHERE id = ?", certIDBase64, certID)
|
||||
if err != nil {
|
||||
log.Printf("Warnung: Fehler beim Speichern der CertID in DB: %v", err)
|
||||
// Weiter mit RenewalInfo-Abfrage, auch wenn DB-Update fehlschlägt
|
||||
}
|
||||
|
||||
// Rufe RenewalInfo ab
|
||||
// TODO: ACME-Provider-ID aus Zertifikat/FQDN-Konfiguration holen
|
||||
// Standardmäßig verwenden wir Let's Encrypt Staging
|
||||
acmeProviderID := "letsencrypt-staging"
|
||||
renewalInfo, err := FetchRenewalInfo(acmeProviderID, certIDBase64)
|
||||
if err != nil {
|
||||
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
|
||||
if strings.Contains(err.Error(), "Staging-Zertifikate") {
|
||||
log.Printf("RenewalInfo wird übersprungen - Staging-Zertifikat (CertID: %s)", certIDBase64)
|
||||
return nil // Kein Fehler, einfach überspringen
|
||||
}
|
||||
return fmt.Errorf("fehler beim Abrufen der RenewalInfo: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("RenewalInfo erhalten: Suggested Window Start: %s, End: %s", renewalInfo.SuggestedWindow.Start, renewalInfo.SuggestedWindow.End)
|
||||
|
||||
// Parse Suggested Window Start
|
||||
scheduledAt, err := time.Parse(time.RFC3339, renewalInfo.SuggestedWindow.Start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Parsen des Scheduled-At-Datums: %v", err)
|
||||
}
|
||||
|
||||
// Speichere renewal_scheduled_at in DB
|
||||
scheduledAtStr := scheduledAt.UTC().Format("2006-01-02 15:04:05")
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(ctx, "UPDATE certificates SET renewal_scheduled_at = ? WHERE id = ?", scheduledAtStr, certID)
|
||||
if err != nil {
|
||||
log.Printf("Warnung: Fehler beim Speichern von renewal_scheduled_at in DB: %v", err)
|
||||
}
|
||||
|
||||
// Erstelle Queue-Eintrag
|
||||
queueID := uuid.New().String()
|
||||
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
`, queueID, certID, fqdnID, spaceID, scheduledAtStr, createdAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Erstellen des Queue-Eintrags: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("RenewalInfo verarbeitet und Queue-Eintrag erstellt (ID: %s, Scheduled: %s)", queueID, scheduledAtStr)
|
||||
return nil
|
||||
}
|
||||
333
backend/renewal_queue_handlers.go
Normal file
333
backend/renewal_queue_handlers.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// updateFqdnRenewalEnabledHandler aktualisiert das renewal_enabled Flag für einen FQDN
|
||||
func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "PUT, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
spaceID := vars["spaceId"]
|
||||
fqdnID := vars["fqdnId"]
|
||||
|
||||
// Prüfe Berechtigung
|
||||
userID, _ := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
hasAccess, err := hasSpaceAccess(userID, spaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Prüfen der Berechtigung: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
http.Error(w, "Keine Berechtigung für diesen Space", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob FQDN existiert und hole aktuellen renewal_enabled Status
|
||||
var currentRenewalEnabled sql.NullInt64
|
||||
err = db.QueryRow("SELECT renewal_enabled FROM fqdns WHERE id = ? AND space_id = ?", fqdnID, spaceID).Scan(¤tRenewalEnabled)
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Laden des FQDN: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Request Body
|
||||
var req struct {
|
||||
RenewalEnabled bool `json:"renewalEnabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Ungültige Anfrage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob renewal_enabled von false auf true geändert wird
|
||||
wasDisabled := !currentRenewalEnabled.Valid || currentRenewalEnabled.Int64 == 0
|
||||
willBeEnabled := req.RenewalEnabled
|
||||
shouldProcessRenewalInfo := wasDisabled && willBeEnabled
|
||||
|
||||
// Beginne Transaktion
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Starten der Transaktion: %v", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Update renewal_enabled (explizit als 0 oder 1 speichern, nicht NULL)
|
||||
var renewalEnabledInt int
|
||||
if req.RenewalEnabled {
|
||||
renewalEnabledInt = 1
|
||||
} else {
|
||||
renewalEnabledInt = 0
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = ? WHERE id = ? AND space_id = ?", renewalEnabledInt, fqdnID, spaceID)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Aktualisieren des renewal_enabled Flags", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Aktualisieren des renewal_enabled Flags: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn renewal_enabled deaktiviert wird, lösche nur pending/processing Queue-Einträge für diesen FQDN
|
||||
// Completed und failed Einträge bleiben als Historie erhalten
|
||||
if !req.RenewalEnabled {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ? AND status IN ('pending', 'processing')", fqdnID)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
|
||||
}
|
||||
|
||||
// Committe die Transaktion
|
||||
if err = tx.Commit(); err != nil {
|
||||
http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Committen der Transaktion: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn renewal_enabled von false auf true geändert wurde, verarbeite RenewalInfo für das aktuelle Zertifikat
|
||||
if shouldProcessRenewalInfo {
|
||||
go func() {
|
||||
// Hole das aktuellste (nicht-intermediate) Zertifikat für diesen FQDN
|
||||
var certID, certPEM string
|
||||
err := db.QueryRow(`
|
||||
SELECT id, certificate_pem
|
||||
FROM certificates
|
||||
WHERE fqdn_id = ? AND (is_intermediate = 0 OR is_intermediate IS NULL)
|
||||
ORDER BY expires_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`, fqdnID).Scan(&certID, &certPEM)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("Kein Zertifikat für FQDN %s gefunden - RenewalInfo wird übersprungen", fqdnID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Laden des Zertifikats für FQDN %s: %v", fqdnID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if certPEM == "" {
|
||||
log.Printf("Zertifikat %s hat kein PEM - RenewalInfo wird übersprungen", certID)
|
||||
return
|
||||
}
|
||||
|
||||
// Verarbeite RenewalInfo im Hintergrund
|
||||
log.Printf("Verarbeite RenewalInfo für Zertifikat %s (FQDN %s wurde aktiviert)", certID, fqdnID)
|
||||
if err := ProcessRenewalInfoForCertificate(certPEM, certID, fqdnID, spaceID, true); err != nil {
|
||||
log.Printf("Fehler beim Verarbeiten der RenewalInfo für FQDN %s (wird ignoriert): %v", fqdnID, err)
|
||||
} else {
|
||||
log.Printf("RenewalInfo erfolgreich verarbeitet für FQDN %s", fqdnID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Hole Username für Audit-Log
|
||||
userID, username := getUserFromRequest(r)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"renewalEnabled": req.RenewalEnabled,
|
||||
})
|
||||
|
||||
// Audit-Log
|
||||
if auditService != nil {
|
||||
ipAddress, userAgent := getRequestInfo(r)
|
||||
auditService.Track(r.Context(), "UPDATE", "fqdn", fqdnID, userID, username, map[string]interface{}{
|
||||
"spaceId": spaceID,
|
||||
"renewalEnabled": req.RenewalEnabled,
|
||||
"message": fmt.Sprintf("Renewal-Status für FQDN aktualisiert: %v", req.RenewalEnabled),
|
||||
}, ipAddress, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
// getRenewalQueueHandler gibt alle Einträge aus der renewal_queue zurück
|
||||
func getRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe Berechtigung - jeder authentifizierte User kann die Queue sehen
|
||||
userID, _ := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Hole alle Queue-Einträge mit FQDN und Space-Informationen
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT
|
||||
rq.id,
|
||||
rq.certificate_id,
|
||||
rq.fqdn_id,
|
||||
rq.space_id,
|
||||
rq.scheduled_at,
|
||||
rq.status,
|
||||
rq.created_at,
|
||||
rq.processed_at,
|
||||
rq.error_message,
|
||||
f.fqdn,
|
||||
s.name as space_name
|
||||
FROM renewal_queue rq
|
||||
LEFT JOIN fqdns f ON rq.fqdn_id = f.id
|
||||
LEFT JOIN spaces s ON rq.space_id = s.id
|
||||
ORDER BY rq.scheduled_at ASC
|
||||
`)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Laden der Renewal Queue", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Laden der Renewal Queue: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var queueItems []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, certID, fqdnID, spaceID, scheduledAt, status, createdAt, fqdn, spaceName string
|
||||
var processedAt, errorMessage sql.NullString
|
||||
|
||||
err := rows.Scan(&id, &certID, &fqdnID, &spaceID, &scheduledAt, &status, &createdAt, &processedAt, &errorMessage, &fqdn, &spaceName)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"id": id,
|
||||
"certificateId": certID,
|
||||
"fqdnId": fqdnID,
|
||||
"spaceId": spaceID,
|
||||
"scheduledAt": scheduledAt,
|
||||
"status": status,
|
||||
"createdAt": createdAt,
|
||||
"fqdn": fqdn,
|
||||
"spaceName": spaceName,
|
||||
}
|
||||
|
||||
if processedAt.Valid {
|
||||
item["processedAt"] = processedAt.String
|
||||
}
|
||||
if errorMessage.Valid {
|
||||
item["errorMessage"] = errorMessage.String
|
||||
}
|
||||
|
||||
queueItems = append(queueItems, item)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"queue": queueItems,
|
||||
})
|
||||
}
|
||||
|
||||
// deleteAllRenewalQueueEntriesHandler löscht alle Einträge aus der Renewal Queue
|
||||
func deleteAllRenewalQueueEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe Berechtigung - nur authentifizierte User
|
||||
userID, username := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe Bestätigung (optional, aber empfohlen)
|
||||
confirm := r.URL.Query().Get("confirm")
|
||||
if confirm != "true" {
|
||||
http.Error(w, "Bestätigung erforderlich. Verwende ?confirm=true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Lösche alle Queue-Einträge
|
||||
result, err := db.ExecContext(ctx, "DELETE FROM renewal_queue")
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alle Renewal Queue-Einträge gelöscht: %d Einträge", rowsAffected)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Alle Renewal Queue-Einträge erfolgreich gelöscht",
|
||||
"deletedCount": rowsAffected,
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
// Audit-Log
|
||||
if auditService != nil {
|
||||
ipAddress, userAgent := getRequestInfo(r)
|
||||
auditService.Track(r.Context(), "DELETE", "renewal_queue", "", userID, username, map[string]interface{}{
|
||||
"deletedCount": rowsAffected,
|
||||
"message": fmt.Sprintf("Alle Renewal Queue-Einträge gelöscht (%d Einträge)", rowsAffected),
|
||||
}, ipAddress, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
350
backend/renewal_scheduler.go
Normal file
350
backend/renewal_scheduler.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"certigo-addon-backend/providers"
|
||||
)
|
||||
|
||||
// StartRenewalScheduler startet den Scheduler für automatische Zertifikatserneuerungen
|
||||
// Der Scheduler prüft regelmäßig die renewal_queue auf fällige Erneuerungen
|
||||
func StartRenewalScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute) // Prüfe alle 5 Minuten
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("Renewal Scheduler gestartet - prüfe alle 5 Minuten auf fällige Erneuerungen")
|
||||
|
||||
// Führe sofort eine Prüfung durch beim Start
|
||||
processRenewalQueue()
|
||||
|
||||
// Dann in regelmäßigen Intervallen
|
||||
for range ticker.C {
|
||||
processRenewalQueue()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// processRenewalQueue prüft die renewal_queue auf fällige Erneuerungen und führt sie aus
|
||||
func processRenewalQueue() {
|
||||
// Verwende kürzeren Context für die Queue-Abfrage, um Datenbankblockierungen zu vermeiden
|
||||
queryCtx, queryCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer queryCancel()
|
||||
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
|
||||
// Hole alle fälligen Queue-Einträge (scheduled_at <= now und status = 'pending')
|
||||
rows, err := db.QueryContext(queryCtx, `
|
||||
SELECT id, certificate_id, fqdn_id, space_id, scheduled_at
|
||||
FROM renewal_queue
|
||||
WHERE status = 'pending' AND scheduled_at <= ?
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT 10
|
||||
`, now)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Abfragen der renewal_queue: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var processedCount int
|
||||
for rows.Next() {
|
||||
var queueID, certID, fqdnID, spaceID, scheduledAt string
|
||||
if err := rows.Scan(&queueID, &certID, &fqdnID, &spaceID, &scheduledAt); err != nil {
|
||||
log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Markiere als in Bearbeitung (mit angemessenem Timeout)
|
||||
// Verwende Retry-Logik für Datenbankoperationen, um Blockierungen zu handhaben
|
||||
var updateErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, updateErr = db.ExecContext(updateCtx, `
|
||||
UPDATE renewal_queue
|
||||
SET status = 'processing', processed_at = ?
|
||||
WHERE id = ?
|
||||
`, time.Now().UTC().Format("2006-01-02 15:04:05"), queueID)
|
||||
updateCancel()
|
||||
if updateErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
log.Printf("Warnung: Fehler beim Aktualisieren des Queue-Status (Versuch %d/3), versuche erneut: %v", retry+1, updateErr)
|
||||
time.Sleep(time.Second * time.Duration(retry+1)) // Exponential backoff
|
||||
}
|
||||
}
|
||||
if updateErr != nil {
|
||||
log.Printf("Fehler beim Aktualisieren des Queue-Status nach 3 Versuchen: %v", updateErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Führe Erneuerung durch (in separater Goroutine, um Datenbankblockierungen zu vermeiden)
|
||||
go func(qID, cID, fID, sID string) {
|
||||
if err := processCertificateRenewal(cID, fID, sID, qID); err != nil {
|
||||
log.Printf("Fehler bei der Zertifikatserneuerung (Queue ID: %s): %v", qID, err)
|
||||
// Markiere als fehlgeschlagen (mit Retry-Logik)
|
||||
var updateErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
errorCtx, errorCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, updateErr = db.ExecContext(errorCtx, `
|
||||
UPDATE renewal_queue
|
||||
SET status = 'failed', error_message = ?
|
||||
WHERE id = ?
|
||||
`, err.Error(), qID)
|
||||
errorCancel()
|
||||
if updateErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
if updateErr != nil {
|
||||
log.Printf("Fehler beim Aktualisieren des Fehlerstatus nach 3 Versuchen: %v", updateErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Markiere als erfolgreich (mit Retry-Logik)
|
||||
var successErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
successCtx, successCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, successErr = db.ExecContext(successCtx, `
|
||||
UPDATE renewal_queue
|
||||
SET status = 'completed'
|
||||
WHERE id = ?
|
||||
`, qID)
|
||||
successCancel()
|
||||
if successErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
if successErr != nil {
|
||||
log.Printf("Fehler beim Markieren als erfolgreich nach 3 Versuchen: %v", successErr)
|
||||
} else {
|
||||
log.Printf("Zertifikatserneuerung erfolgreich verarbeitet (Queue ID: %s, Cert ID: %s, FQDN: %s)", qID, cID, fID)
|
||||
}
|
||||
}(queueID, certID, fqdnID, spaceID)
|
||||
|
||||
processedCount++
|
||||
}
|
||||
|
||||
if processedCount > 0 {
|
||||
log.Printf("Renewal Queue: %d Erneuerungen gestartet", processedCount)
|
||||
}
|
||||
}
|
||||
|
||||
// processCertificateRenewal führt die tatsächliche Zertifikatserneuerung durch
|
||||
func processCertificateRenewal(certID, fqdnID, spaceID, queueID string) error {
|
||||
// Lade FQDN-Daten (mit angemessenem Timeout)
|
||||
var fqdn FQDN
|
||||
var acmeProviderID, acmeUsername, acmePassword, acmeEmail, acmeKeyID sql.NullString
|
||||
var renewalEnabled sql.NullInt64
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, space_id, fqdn, acme_provider_id, acme_username, acme_password, acme_email, acme_key_id, COALESCE(renewal_enabled, 1)
|
||||
FROM fqdns
|
||||
WHERE id = ? AND space_id = ?
|
||||
`, fqdnID, spaceID).Scan(
|
||||
&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN,
|
||||
&acmeProviderID, &acmeUsername, &acmePassword, &acmeEmail, &acmeKeyID, &renewalEnabled,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if acmeProviderID.String != "certigo-acmeproxy" || !acmeUsername.Valid || !acmePassword.Valid || !acmeEmail.Valid {
|
||||
return fmt.Errorf("fqdn hat keine gültigen ACME-Daten")
|
||||
}
|
||||
|
||||
fqdn.AcmeProviderID = acmeProviderID.String
|
||||
fqdn.AcmeUsername = acmeUsername.String
|
||||
fqdn.AcmePassword = acmePassword.String
|
||||
fqdn.AcmeEmail = acmeEmail.String
|
||||
if acmeKeyID.Valid {
|
||||
fqdn.AcmeKeyID = acmeKeyID.String
|
||||
}
|
||||
|
||||
// Lade Provider-Konfiguration
|
||||
pm := providers.GetManager()
|
||||
provider, exists := pm.GetProvider("certigo-acmeproxy")
|
||||
if !exists || provider == nil {
|
||||
return fmt.Errorf("acme provider nicht gefunden")
|
||||
}
|
||||
|
||||
config, err := pm.GetProviderConfig("certigo-acmeproxy")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Fehler beim Laden der Provider-Konfiguration: %v", err)
|
||||
}
|
||||
|
||||
acmeProxyProvider, ok := provider.(*providers.CertigoACMEProxyProvider)
|
||||
if !ok {
|
||||
return fmt.Errorf("Ungültiger Provider-Typ")
|
||||
}
|
||||
|
||||
// Erstelle Update- und Cleanup-Funktionen (mit Retry-Logik)
|
||||
updateTokenFunc := func(token string) error {
|
||||
var updateErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, updateErr = db.ExecContext(updateCtx, "UPDATE fqdns SET acme_challenge_token = ? WHERE id = ?", token, fqdnID)
|
||||
updateCancel()
|
||||
if updateErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
if updateErr != nil {
|
||||
return fmt.Errorf("fehler beim Speichern des Challenge-Tokens: %v", updateErr)
|
||||
}
|
||||
return acmeProxyProvider.UpdateChallengeToken(fqdn.AcmeUsername, fqdn.AcmePassword, token, config.Settings)
|
||||
}
|
||||
|
||||
cleanupTokenFunc := func() error {
|
||||
var cleanupErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, cleanupErr = db.ExecContext(cleanupCtx, "UPDATE fqdns SET acme_challenge_token = NULL WHERE id = ?", fqdnID)
|
||||
cleanupCancel()
|
||||
if cleanupErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
// Status-Callback (optional, für Logging)
|
||||
statusCallback := func(status string) {
|
||||
log.Printf("[Renewal Queue %s] Status: %s", queueID, status)
|
||||
}
|
||||
|
||||
// Generiere TraceID
|
||||
traceID := generateTraceID()
|
||||
log.Printf("Starte automatische Zertifikatserneuerung (Queue ID: %s, TraceID: %s, FQDN: %s)", queueID, traceID, fqdn.FQDN)
|
||||
|
||||
// Erstelle ACME-Client-Kontext
|
||||
// Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen
|
||||
acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen
|
||||
acmeCtx, err := NewACMEClientContext(acmeProviderIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Initialisieren des ACME-Providers: %v", err)
|
||||
}
|
||||
|
||||
// Prüfe ob der KeyID zum aktuellen Provider passt
|
||||
// Wenn der FQDN noch den alten Provider (certigo-acmeproxy) hat, aber wir jetzt einen direkten ACME-Provider verwenden,
|
||||
// muss der KeyID ignoriert werden, da er zu einem anderen Provider gehört
|
||||
keyIDToUse := fqdn.AcmeKeyID
|
||||
if fqdn.AcmeProviderID == "certigo-acmeproxy" && acmeProviderIDStr != "certigo-acmeproxy" {
|
||||
// Provider hat sich geändert - KeyID ist nicht mehr gültig
|
||||
log.Printf("Provider hat sich geändert (%s -> %s), erstelle neuen Account", fqdn.AcmeProviderID, acmeProviderIDStr)
|
||||
keyIDToUse = "" // Erzwinge neue Account-Erstellung
|
||||
}
|
||||
|
||||
// Beantrage neues Zertifikat
|
||||
baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.")
|
||||
result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, keyIDToUse, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err)
|
||||
}
|
||||
|
||||
// Speichere neues Zertifikat
|
||||
if result.Certificate != "" && result.PrivateKey != "" {
|
||||
newCertID := uuid.New().String()
|
||||
certificateID := result.OrderURL
|
||||
if certificateID == "" {
|
||||
certificateID = newCertID
|
||||
}
|
||||
|
||||
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
expiresAt, isIntermediate, parseErr := ParseCertificate(result.Certificate)
|
||||
var expiresAtStr string
|
||||
var isIntermediateInt int
|
||||
if parseErr == nil {
|
||||
expiresAtStr = expiresAt.UTC().Format("2006-01-02 15:04:05")
|
||||
if isIntermediate {
|
||||
isIntermediateInt = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Verwende separaten Context für INSERT mit Retry-Logik
|
||||
var insertErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
insertCtx, insertCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, insertErr = db.ExecContext(insertCtx, `
|
||||
INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, newCertID, fqdnID, spaceID, nil, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt)
|
||||
insertCancel()
|
||||
if insertErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
if insertErr != nil {
|
||||
return fmt.Errorf("fehler beim Speichern des neuen Zertifikats: %v", insertErr)
|
||||
}
|
||||
|
||||
// Markiere altes Zertifikat (optional, falls gewünscht)
|
||||
// Hier könnten wir z.B. den Status auf 'replaced' setzen
|
||||
|
||||
// Verarbeite RenewalInfo für das neue Zertifikat (falls aktiviert)
|
||||
renewalEnabledValue := true
|
||||
if renewalEnabled.Valid {
|
||||
renewalEnabledValue = renewalEnabled.Int64 == 1
|
||||
}
|
||||
|
||||
if renewalEnabledValue {
|
||||
go func() {
|
||||
if err := ProcessRenewalInfoForCertificate(result.Certificate, newCertID, fqdnID, spaceID, true); err != nil {
|
||||
log.Printf("Fehler beim Verarbeiten der RenewalInfo für erneuertes Zertifikat (wird ignoriert): %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
log.Printf("Automatische Zertifikatserneuerung erfolgreich abgeschlossen (Queue ID: %s, Neues Cert ID: %s)", queueID, newCertID)
|
||||
}
|
||||
|
||||
// Update KeyID falls geändert (mit Retry-Logik)
|
||||
if result.KeyID != "" && result.KeyID != fqdn.AcmeKeyID {
|
||||
var keyIDErr error
|
||||
for retry := 0; retry < 3; retry++ {
|
||||
keyIDCtx, keyIDCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
_, keyIDErr = db.ExecContext(keyIDCtx, "UPDATE fqdns SET acme_key_id = ? WHERE id = ?", result.KeyID, fqdnID)
|
||||
keyIDCancel()
|
||||
if keyIDErr == nil {
|
||||
break
|
||||
}
|
||||
if retry < 2 {
|
||||
time.Sleep(time.Second * time.Duration(retry+1))
|
||||
}
|
||||
}
|
||||
if keyIDErr != nil {
|
||||
log.Printf("Warnung: Fehler beim Speichern der neuen KeyID nach 3 Versuchen: %v", keyIDErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
182
backend/renewal_test_handlers.go
Normal file
182
backend/renewal_test_handlers.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestCreateRenewalQueueEntryRequest ist die Request-Struktur für das Erstellen eines Test-Queue-Eintrags
|
||||
type TestCreateRenewalQueueEntryRequest struct {
|
||||
CertificateID string `json:"certificateId"`
|
||||
FQDNID string `json:"fqdnId"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
MinutesFromNow int `json:"minutesFromNow"` // Negative Werte = in der Vergangenheit (sofort fällig)
|
||||
}
|
||||
|
||||
// createTestRenewalQueueEntryHandler erstellt einen Test-Queue-Eintrag für die Renewal-Funktion
|
||||
// Nur für Administratoren zugänglich
|
||||
func createTestRenewalQueueEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob User Admin ist
|
||||
userID, _ := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin, err := isUserAdmin(userID)
|
||||
if err != nil || !isAdmin {
|
||||
http.Error(w, "Nur Administratoren können Test-Queue-Einträge erstellen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse Request Body
|
||||
var req TestCreateRenewalQueueEntryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Ungültige Request-Daten", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validiere Eingaben
|
||||
if req.CertificateID == "" || req.FQDNID == "" || req.SpaceID == "" {
|
||||
http.Error(w, "certificateId, fqdnId und spaceId sind erforderlich", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob Zertifikat existiert
|
||||
var certExists bool
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM certificates WHERE id = ?)", req.CertificateID).Scan(&certExists)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Prüfen des Zertifikats", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Prüfen des Zertifikats: %v", err)
|
||||
return
|
||||
}
|
||||
if !certExists {
|
||||
http.Error(w, "Zertifikat nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob FQDN existiert
|
||||
var fqdnExists bool
|
||||
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM fqdns WHERE id = ?)", req.FQDNID).Scan(&fqdnExists)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Prüfen des FQDN: %v", err)
|
||||
return
|
||||
}
|
||||
if !fqdnExists {
|
||||
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Berechne scheduled_at Zeitpunkt
|
||||
now := time.Now().UTC()
|
||||
scheduledAt := now.Add(time.Duration(req.MinutesFromNow) * time.Minute)
|
||||
scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
// Generiere eindeutige Queue-ID
|
||||
queueID := fmt.Sprintf("test-%s", uuid.New().String())
|
||||
createdAt := now.Format("2006-01-02 15:04:05")
|
||||
|
||||
// Erstelle Queue-Eintrag
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
`, queueID, req.CertificateID, req.FQDNID, req.SpaceID, scheduledAtStr, createdAt)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Erstellen des Queue-Eintrags", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Lade erstellten Eintrag
|
||||
var entry struct {
|
||||
ID string `json:"id"`
|
||||
CertificateID string `json:"certificateId"`
|
||||
FQDNID string `json:"fqdnId"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
ScheduledAt string `json:"scheduledAt"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
err = db.QueryRowContext(ctx, `
|
||||
SELECT id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at
|
||||
FROM renewal_queue
|
||||
WHERE id = ?
|
||||
`, queueID).Scan(&entry.ID, &entry.CertificateID, &entry.FQDNID, &entry.SpaceID, &entry.ScheduledAt, &entry.Status, &entry.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Abrufen des erstellten Eintrags", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Abrufen des erstellten Eintrags: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"entry": entry,
|
||||
"message": fmt.Sprintf("Test-Queue-Eintrag erstellt (geplant: %s)", scheduledAtStr),
|
||||
})
|
||||
}
|
||||
|
||||
// triggerRenewalQueueHandler führt die Queue-Verarbeitung manuell aus
|
||||
// Nur für Administratoren zugänglich
|
||||
func triggerRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe ob User Admin ist
|
||||
userID, _ := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin, err := isUserAdmin(userID)
|
||||
if err != nil || !isAdmin {
|
||||
http.Error(w, "Nur Administratoren können die Queue-Verarbeitung manuell auslösen", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Führe Queue-Verarbeitung in einer Goroutine aus, um den Request nicht zu blockieren
|
||||
go func() {
|
||||
log.Println("Manuelle Queue-Verarbeitung gestartet (via Test-Endpoint)")
|
||||
processRenewalQueue()
|
||||
log.Println("Manuelle Queue-Verarbeitung abgeschlossen")
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Queue-Verarbeitung wurde gestartet (läuft im Hintergrund)",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Test-Skripte für Audit-Logs
|
||||
# Test-Skripte
|
||||
|
||||
## Test-Logs generieren
|
||||
|
||||
@@ -40,3 +40,72 @@ curl -X DELETE "http://localhost:8080/api/audit-logs?confirm=true" \
|
||||
|
||||
**Wichtig**: Der `confirm=true` Query-Parameter ist erforderlich, um versehentliches Löschen zu verhindern.
|
||||
|
||||
## Renewal-Funktion testen
|
||||
|
||||
Das Skript `test_renewal.go` erstellt Test-Queue-Einträge für die Renewal-Funktion.
|
||||
|
||||
### Verwendung:
|
||||
|
||||
```bash
|
||||
cd backend/testing/scripts
|
||||
go run test_renewal.go
|
||||
```
|
||||
|
||||
### Was wird erstellt:
|
||||
|
||||
- Test-Queue-Einträge mit verschiedenen Zeitstempeln:
|
||||
- Einer sofort fällig (vor 1 Minute)
|
||||
- Einer in 5 Minuten
|
||||
- Einer in 10 Minuten
|
||||
- Verwendet existierende FQDNs mit Zertifikaten
|
||||
- Zeigt Queue-Status an
|
||||
|
||||
### Manuelle Tests über API:
|
||||
|
||||
#### 1. Test-Queue-Eintrag erstellen:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \
|
||||
-u admin:admin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"certificateId": "CERT_ID",
|
||||
"fqdnId": "FQDN_ID",
|
||||
"spaceId": "SPACE_ID",
|
||||
"minutesFromNow": -5
|
||||
}'
|
||||
```
|
||||
|
||||
**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig).
|
||||
|
||||
#### 2. Queue-Verarbeitung manuell auslösen:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \
|
||||
-u admin:admin \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten.
|
||||
|
||||
#### 3. Queue-Status abrufen:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/renewal-queue" \
|
||||
-u admin:admin
|
||||
```
|
||||
|
||||
### Aufräumen:
|
||||
|
||||
Test-Queue-Einträge können über SQL gelöscht werden:
|
||||
|
||||
```sql
|
||||
DELETE FROM renewal_queue WHERE id LIKE 'test-%';
|
||||
```
|
||||
|
||||
Oder über die Datenbank:
|
||||
|
||||
```bash
|
||||
sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';"
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
79
backend/testing/README.md
Normal file
79
backend/testing/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Testing Tools
|
||||
|
||||
Dieser Ordner enthält Test-Skripte für die Renewal-Funktion.
|
||||
|
||||
## Struktur
|
||||
|
||||
- `scripts/test_renewal.go` - Skript zum Erstellen von Test-Queue-Einträgen
|
||||
|
||||
**Hinweis**: Die Test-Handler (`renewal_test_handlers.go`) befinden sich im Hauptverzeichnis (`backend/`), da sie Teil des `package main` sein müssen, um von `main.go` aufgerufen werden zu können.
|
||||
|
||||
## Test-Handler
|
||||
|
||||
Die Test-Handler werden automatisch in `main.go` registriert und sind nur für Administratoren zugänglich:
|
||||
|
||||
- `POST /api/renewal-queue/test/create` - Erstellt einen Test-Queue-Eintrag
|
||||
- `POST /api/renewal-queue/test/trigger` - Führt die Queue-Verarbeitung manuell aus
|
||||
|
||||
## Test-Skript
|
||||
|
||||
Das Test-Skript erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln:
|
||||
|
||||
```bash
|
||||
cd backend/testing/scripts
|
||||
go run test_renewal.go
|
||||
```
|
||||
|
||||
Das Skript:
|
||||
- Findet existierende FQDNs mit Zertifikaten
|
||||
- Erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln
|
||||
- Zeigt den aktuellen Queue-Status an
|
||||
|
||||
## Manuelle Tests über API
|
||||
|
||||
### 1. Test-Queue-Eintrag erstellen:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \
|
||||
-u admin:admin \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"certificateId": "CERT_ID",
|
||||
"fqdnId": "FQDN_ID",
|
||||
"spaceId": "SPACE_ID",
|
||||
"minutesFromNow": -5
|
||||
}'
|
||||
```
|
||||
|
||||
**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig).
|
||||
|
||||
### 2. Queue-Verarbeitung manuell auslösen:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \
|
||||
-u admin:admin \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten.
|
||||
|
||||
### 3. Queue-Status abrufen:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/renewal-queue" \
|
||||
-u admin:admin
|
||||
```
|
||||
|
||||
## Aufräumen
|
||||
|
||||
Test-Queue-Einträge können über SQL gelöscht werden:
|
||||
|
||||
```sql
|
||||
DELETE FROM renewal_queue WHERE id LIKE 'test-%';
|
||||
```
|
||||
|
||||
Oder über die Datenbank:
|
||||
|
||||
```bash
|
||||
sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';"
|
||||
```
|
||||
223
backend/testing/scripts/test_renewal.go
Normal file
223
backend/testing/scripts/test_renewal.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Test-Skript für Renewal-Funktion
|
||||
// Erstellt Test-Queue-Einträge mit vergangenen Zeitstempeln, um sofortige Tests zu ermöglichen
|
||||
|
||||
func main() {
|
||||
// Konfiguration
|
||||
// Datenbankpfad relativ zum backend-Verzeichnis (2 Ebenen höher)
|
||||
dbPath := "../../spaces.db"
|
||||
apiURL := "http://localhost:8080"
|
||||
username := "admin"
|
||||
password := "admin"
|
||||
|
||||
// Öffne Datenbank
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1&_journal_mode=WAL")
|
||||
if err != nil {
|
||||
log.Fatalf("Fehler beim Öffnen der Datenbank: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Teste Verbindung
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
log.Fatalf("Fehler beim Verbinden mit der Datenbank: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("=== Renewal Test-Skript ===")
|
||||
fmt.Println()
|
||||
|
||||
// 1. Hole existierende FQDNs mit Zertifikaten
|
||||
fmt.Println("1. Suche nach FQDNs mit Zertifikaten...")
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT DISTINCT
|
||||
f.id as fqdn_id,
|
||||
f.space_id,
|
||||
f.fqdn,
|
||||
c.id as cert_id
|
||||
FROM fqdns f
|
||||
INNER JOIN certificates c ON c.fqdn_id = f.id
|
||||
WHERE f.acme_provider_id = 'certigo-acmeproxy'
|
||||
LIMIT 5
|
||||
`)
|
||||
if err != nil {
|
||||
log.Fatalf("Fehler beim Abfragen der FQDNs: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var fqdns []map[string]string
|
||||
for rows.Next() {
|
||||
var fqdnID, spaceID, fqdn, certID string
|
||||
if err := rows.Scan(&fqdnID, &spaceID, &fqdn, &certID); err != nil {
|
||||
log.Printf("Fehler beim Scannen: %v", err)
|
||||
continue
|
||||
}
|
||||
fqdns = append(fqdns, map[string]string{
|
||||
"fqdnId": fqdnID,
|
||||
"spaceId": spaceID,
|
||||
"fqdn": fqdn,
|
||||
"certId": certID,
|
||||
})
|
||||
}
|
||||
|
||||
if len(fqdns) == 0 {
|
||||
log.Fatal("Keine FQDNs mit Zertifikaten gefunden. Bitte erstelle zuerst ein Zertifikat.")
|
||||
}
|
||||
|
||||
fmt.Printf(" Gefunden: %d FQDNs\n", len(fqdns))
|
||||
for _, f := range fqdns {
|
||||
fmt.Printf(" - %s (FQDN ID: %s, Cert ID: %s)\n", f["fqdn"], f["fqdnId"], f["certId"])
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 2. Erstelle Test-Queue-Einträge mit vergangenen Zeitstempeln
|
||||
fmt.Println("2. Erstelle Test-Queue-Einträge...")
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Erstelle Einträge mit verschiedenen Zeitstempeln:
|
||||
// - Einer sofort fällig (vor 1 Minute)
|
||||
// - Einer in 5 Minuten
|
||||
// - Einer in 10 Minuten
|
||||
testTimes := []time.Time{
|
||||
now.Add(-1 * time.Minute), // Sofort fällig
|
||||
now.Add(5 * time.Minute), // In 5 Minuten
|
||||
now.Add(10 * time.Minute), // In 10 Minuten
|
||||
}
|
||||
|
||||
createdCount := 0
|
||||
for i, fqdn := range fqdns {
|
||||
if i >= len(testTimes) {
|
||||
break
|
||||
}
|
||||
|
||||
scheduledAt := testTimes[i]
|
||||
scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05")
|
||||
queueID := fmt.Sprintf("test-%d-%d", time.Now().Unix(), i)
|
||||
createdAt := now.Format("2006-01-02 15:04:05")
|
||||
|
||||
// Prüfe ob Eintrag bereits existiert
|
||||
var exists bool
|
||||
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM renewal_queue WHERE id = ?)", queueID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Prüfen: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if exists {
|
||||
// Lösche existierenden Eintrag
|
||||
_, err = db.ExecContext(ctx, "DELETE FROM renewal_queue WHERE id = ?", queueID)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Löschen: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Erstelle neuen Eintrag
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
||||
`, queueID, fqdn["certId"], fqdn["fqdnId"], fqdn["spaceId"], scheduledAtStr, createdAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ Queue-Eintrag erstellt: %s für %s (geplant: %s)\n", queueID, fqdn["fqdn"], scheduledAtStr)
|
||||
createdCount++
|
||||
}
|
||||
|
||||
fmt.Printf("\n %d Queue-Einträge erstellt\n", createdCount)
|
||||
fmt.Println()
|
||||
|
||||
// 3. Zeige Queue-Status
|
||||
fmt.Println("3. Aktuelle Queue-Status:")
|
||||
queueRows, err := db.QueryContext(ctx, `
|
||||
SELECT
|
||||
rq.id,
|
||||
rq.scheduled_at,
|
||||
rq.status,
|
||||
f.fqdn
|
||||
FROM renewal_queue rq
|
||||
LEFT JOIN fqdns f ON rq.fqdn_id = f.id
|
||||
WHERE rq.id LIKE 'test-%'
|
||||
ORDER BY rq.scheduled_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Abfragen der Queue: %v", err)
|
||||
} else {
|
||||
defer queueRows.Close()
|
||||
for queueRows.Next() {
|
||||
var id, scheduledAt, status, fqdn string
|
||||
if err := queueRows.Scan(&id, &scheduledAt, &status, &fqdn); err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" - %s: %s (Status: %s, FQDN: %s)\n", id, scheduledAt, status, fqdn)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 4. Teste API-Endpunkt (manueller Trigger)
|
||||
fmt.Println("4. Teste manuellen Queue-Trigger über API...")
|
||||
fmt.Println(" (Dies würde normalerweise automatisch vom Scheduler ausgeführt)")
|
||||
fmt.Println()
|
||||
|
||||
// Erstelle Basic Auth Header
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
|
||||
|
||||
// Teste Queue-Status über API
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequest("GET", apiURL+"/api/renewal-queue", nil)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Erstellen des Requests: %v", err)
|
||||
} else {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Abrufen der Queue: %v", err)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Queue []map[string]interface{} `json:"queue"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
|
||||
fmt.Printf(" ✓ API-Antwort erhalten: %d Einträge in der Queue\n", len(result.Queue))
|
||||
for _, item := range result.Queue {
|
||||
if id, ok := item["id"].(string); ok && len(id) > 5 && id[:5] == "test-" {
|
||||
fmt.Printf(" - Test-Eintrag: %s (Status: %v, Scheduled: %v)\n",
|
||||
id, item["status"], item["scheduledAt"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("=== Test abgeschlossen ===")
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println("1. Der Scheduler sollte automatisch die fälligen Einträge verarbeiten (alle 5 Minuten)")
|
||||
fmt.Println("2. Oder warte auf die nächste Scheduler-Ausführung")
|
||||
fmt.Println("3. Prüfe die Logs für Verarbeitungsdetails")
|
||||
fmt.Println()
|
||||
fmt.Println("Zum Aufräumen der Test-Einträge:")
|
||||
fmt.Println(" DELETE FROM renewal_queue WHERE id LIKE 'test-%';")
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
865
docs/AUTO_RENEWAL_KONZEPT.md
Normal file
865
docs/AUTO_RENEWAL_KONZEPT.md
Normal file
@@ -0,0 +1,865 @@
|
||||
# Automatische Zertifikats-Erneuerung Konzept für Let's Encrypt
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
### 1.1 Ziel
|
||||
Implementierung einer automatischen Erneuerungsfunktion für Let's Encrypt (LE) Zertifikate, die ablaufende Zertifikate rechtzeitig erneuert, bevor sie ablaufen.
|
||||
|
||||
### 1.2 Anforderungen
|
||||
- **Proaktive Erneuerung**: Zertifikate werden erneuert, bevor sie ablaufen (z.B. 30 Tage vor Ablauf)
|
||||
- **Automatische Ausführung**: Läuft im Hintergrund ohne Benutzerinteraktion
|
||||
- **Fehlerbehandlung**: Robustes Error-Handling und Retry-Mechanismus
|
||||
- **Logging & Monitoring**: Umfassendes Logging für Nachverfolgbarkeit
|
||||
- **Konfigurierbarkeit**: Erneuerungs-Schwellenwerte und Intervalle konfigurierbar
|
||||
- **Berechtigungen**: Respektiert bestehende Permission-Systeme
|
||||
- **DNS-Validierung**: Automatische DNS-Challenge-Validierung vor Erneuerung
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur
|
||||
|
||||
### 2.1 Komponenten-Übersicht
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Auto-Renewal System │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ Scheduler │───>│ Certificate │───>│ Renewal │ │
|
||||
│ │ (Cron) │ │ Scanner │ │ Worker │ │
|
||||
│ └──────────────┘ └──────────────┘ └─────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ v v v │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ Config │ │ Database │ │ ACME │ │
|
||||
│ │ Manager │ │ Queries │ │ Client │ │
|
||||
│ └──────────────┘ └──────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
|
||||
│ │ Logger │ │ Notifier │ │ Retry │ │
|
||||
│ │ Service │ │ Service │ │ Manager │ │
|
||||
│ └──────────────┘ └──────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 Workflow
|
||||
|
||||
```
|
||||
1. Scheduler startet (z.B. täglich um 02:00 Uhr)
|
||||
│
|
||||
├─> 2. Scanner identifiziert ablaufende Zertifikate
|
||||
│ (expires_at < now + renewal_threshold)
|
||||
│
|
||||
├─> 3. Für jedes Zertifikat:
|
||||
│ │
|
||||
│ ├─> 3.1 Prüfe ob Auto-Renewal aktiviert
|
||||
│ │
|
||||
│ ├─> 3.2 Prüfe ob bereits Erneuerung läuft
|
||||
│ │
|
||||
│ ├─> 3.3 Prüfe Berechtigungen (Space-Zugriff)
|
||||
│ │
|
||||
│ ├─> 3.4 Validiere DNS (CNAME Check)
|
||||
│ │
|
||||
│ ├─> 3.5 Erstelle Renewal-Job
|
||||
│ │
|
||||
│ └─> 3.6 Queue für Worker
|
||||
│
|
||||
├─> 4. Worker verarbeitet Jobs sequenziell
|
||||
│ │
|
||||
│ ├─> 4.1 Hole FQDN-Informationen
|
||||
│ │
|
||||
│ ├─> 4.2 Hole ACME-Provider-Konfiguration
|
||||
│ │
|
||||
│ ├─> 4.3 Rufe RequestCertificate() auf
|
||||
│ │
|
||||
│ ├─> 4.4 Speichere neues Zertifikat
|
||||
│ │
|
||||
│ ├─> 4.5 Markiere altes Zertifikat als "replaced"
|
||||
│ │
|
||||
│ └─> 4.6 Logge Erfolg/Fehler
|
||||
│
|
||||
└─> 5. Cleanup & Reporting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank-Schema
|
||||
|
||||
### 3.1 Erweiterte Certificates-Tabelle
|
||||
|
||||
```sql
|
||||
-- Migration: Erweitere certificates-Tabelle um Auto-Renewal-Felder
|
||||
ALTER TABLE certificates ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1;
|
||||
ALTER TABLE certificates ADD COLUMN renewal_attempts INTEGER DEFAULT 0;
|
||||
ALTER TABLE certificates ADD COLUMN last_renewal_attempt DATETIME;
|
||||
ALTER TABLE certificates ADD COLUMN next_renewal_check DATETIME;
|
||||
ALTER TABLE certificates ADD COLUMN renewal_status TEXT; -- 'pending', 'in_progress', 'success', 'failed', 'disabled'
|
||||
ALTER TABLE certificates ADD COLUMN replaced_by_cert_id TEXT; -- ID des neuen Zertifikats
|
||||
ALTER TABLE certificates ADD COLUMN replaces_cert_id TEXT; -- ID des ersetzten Zertifikats
|
||||
```
|
||||
|
||||
### 3.2 Neue Tabelle: certificate_renewal_logs
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS certificate_renewal_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
certificate_id TEXT NOT NULL,
|
||||
fqdn_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL,
|
||||
renewal_status TEXT NOT NULL, -- 'started', 'success', 'failed', 'skipped'
|
||||
renewal_reason TEXT, -- 'expiring_soon', 'manual', 'retry'
|
||||
error_message TEXT,
|
||||
old_expires_at DATETIME,
|
||||
new_expires_at DATETIME,
|
||||
new_certificate_id TEXT,
|
||||
renewal_duration_seconds INTEGER,
|
||||
trace_id TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_renewal_logs_certificate_id ON certificate_renewal_logs(certificate_id);
|
||||
CREATE INDEX idx_renewal_logs_created_at ON certificate_renewal_logs(created_at);
|
||||
CREATE INDEX idx_renewal_logs_status ON certificate_renewal_logs(renewal_status);
|
||||
```
|
||||
|
||||
### 3.3 Neue Tabelle: renewal_config
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS renewal_config (
|
||||
id TEXT PRIMARY KEY DEFAULT 'global',
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
renewal_threshold_days INTEGER DEFAULT 30, -- Erneuere X Tage vor Ablauf
|
||||
check_interval_hours INTEGER DEFAULT 24, -- Wie oft prüfen (in Stunden)
|
||||
max_renewal_attempts INTEGER DEFAULT 3, -- Max. Versuche pro Zertifikat
|
||||
retry_delay_hours INTEGER DEFAULT 24, -- Wartezeit zwischen Retries
|
||||
notification_enabled BOOLEAN DEFAULT 0,
|
||||
notification_email TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Initiale Konfiguration einfügen
|
||||
INSERT INTO renewal_config (id, enabled, renewal_threshold_days, check_interval_hours, max_renewal_attempts, retry_delay_hours, created_at, updated_at)
|
||||
VALUES ('global', 1, 30, 24, 3, 24, datetime('now'), datetime('now'));
|
||||
```
|
||||
|
||||
### 3.4 FQDN-Tabelle Erweiterung
|
||||
|
||||
```sql
|
||||
-- Optional: Pro-FQDN Auto-Renewal-Einstellungen
|
||||
ALTER TABLE fqdns ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Konfiguration
|
||||
|
||||
### 4.1 Global Configuration (Environment Variables)
|
||||
|
||||
```bash
|
||||
# Auto-Renewal Einstellungen
|
||||
AUTO_RENEWAL_ENABLED=true
|
||||
AUTO_RENEWAL_THRESHOLD_DAYS=30
|
||||
AUTO_RENEWAL_CHECK_INTERVAL_HOURS=24
|
||||
AUTO_RENEWAL_SCHEDULE="0 2 * * *" # Cron-Format: Täglich um 02:00 Uhr
|
||||
AUTO_RENEWAL_MAX_ATTEMPTS=3
|
||||
AUTO_RENEWAL_RETRY_DELAY_HOURS=24
|
||||
|
||||
# Notifications
|
||||
AUTO_RENEWAL_NOTIFICATIONS_ENABLED=false
|
||||
AUTO_RENEWAL_NOTIFICATION_EMAIL=admin@example.com
|
||||
|
||||
# Concurrency
|
||||
AUTO_RENEWAL_MAX_CONCURRENT=1 # Anzahl paralleler Erneuerungen
|
||||
```
|
||||
|
||||
### 4.2 Per-FQDN Configuration
|
||||
|
||||
- **Default**: Auto-Renewal aktiviert für alle FQDNs
|
||||
- **Opt-out**: Pro FQDN deaktivierbar über `fqdns.auto_renewal_enabled`
|
||||
- **Opt-out**: Pro Zertifikat deaktivierbar über `certificates.auto_renewal_enabled`
|
||||
|
||||
---
|
||||
|
||||
## 5. Scheduler-Implementierung
|
||||
|
||||
### 5.1 Optionen
|
||||
|
||||
#### Option A: Go Cron Library (Empfohlen)
|
||||
```go
|
||||
import "github.com/robfig/cron/v3"
|
||||
|
||||
c := cron.New()
|
||||
c.AddFunc("0 2 * * *", func() {
|
||||
runCertificateRenewalScan()
|
||||
})
|
||||
c.Start()
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Einfach zu implementieren
|
||||
- Gut getestet
|
||||
- Cron-Format unterstützt
|
||||
|
||||
**Nachteile:**
|
||||
- Läuft nur im Backend-Prozess
|
||||
- Bei Neustart muss Scheduler neu gestartet werden
|
||||
|
||||
#### Option B: Separate Background Service
|
||||
```go
|
||||
// Separate Go-Routine die kontinuierlich läuft
|
||||
go func() {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
runCertificateRenewalScan()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Einfacher zu debuggen
|
||||
- Keine externe Dependency
|
||||
|
||||
**Nachteile:**
|
||||
- Weniger flexibel als Cron
|
||||
- Muss selbst implementiert werden
|
||||
|
||||
#### Option C: System Cron Job
|
||||
```bash
|
||||
# /etc/cron.d/certigo-renewal
|
||||
0 2 * * * curl -X POST http://localhost:8080/api/internal/renewal/scan
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Unabhängig vom Backend-Prozess
|
||||
- Läuft auch wenn Backend neu gestartet wird
|
||||
|
||||
**Nachteile:**
|
||||
- Externe Dependency (curl)
|
||||
- Schwieriger zu debuggen
|
||||
- Benötigt separaten API-Endpunkt
|
||||
|
||||
**Empfehlung: Option A (Go Cron Library)**
|
||||
|
||||
---
|
||||
|
||||
## 6. Certificate Scanner
|
||||
|
||||
### 6.1 Query für ablaufende Zertifikate
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
c.id,
|
||||
c.fqdn_id,
|
||||
c.space_id,
|
||||
c.certificate_id,
|
||||
c.provider_id,
|
||||
c.expires_at,
|
||||
c.auto_renewal_enabled,
|
||||
c.renewal_status,
|
||||
c.renewal_attempts,
|
||||
c.last_renewal_attempt,
|
||||
f.fqdn,
|
||||
f.acme_email,
|
||||
f.acme_key_id,
|
||||
f.provider_id as fqdn_provider_id
|
||||
FROM certificates c
|
||||
INNER JOIN fqdns f ON c.fqdn_id = f.id
|
||||
WHERE
|
||||
-- Nur Leaf-Zertifikate (nicht Intermediate)
|
||||
c.is_intermediate = 0
|
||||
-- Nur Let's Encrypt Zertifikate (via certigo-acmeproxy)
|
||||
AND c.provider_id = 'certigo-acmeproxy'
|
||||
-- Nur gültige/ausgestellte Zertifikate
|
||||
AND c.status = 'issued'
|
||||
-- Auto-Renewal muss aktiviert sein
|
||||
AND (c.auto_renewal_enabled IS NULL OR c.auto_renewal_enabled = 1)
|
||||
AND (f.auto_renewal_enabled IS NULL OR f.auto_renewal_enabled = 1)
|
||||
-- Zertifikat läuft bald ab
|
||||
AND c.expires_at IS NOT NULL
|
||||
AND datetime(c.expires_at) <= datetime('now', '+' || ? || ' days')
|
||||
-- Keine laufende Erneuerung
|
||||
AND (c.renewal_status IS NULL OR c.renewal_status != 'in_progress')
|
||||
-- Nicht zu viele Versuche
|
||||
AND (c.renewal_attempts IS NULL OR c.renewal_attempts < ?)
|
||||
-- Retry-Delay eingehalten
|
||||
AND (
|
||||
c.last_renewal_attempt IS NULL
|
||||
OR datetime(c.last_renewal_attempt) <= datetime('now', '-' || ? || ' hours')
|
||||
)
|
||||
ORDER BY c.expires_at ASC;
|
||||
```
|
||||
|
||||
### 6.2 Filter-Logik
|
||||
|
||||
**Ausschluss-Kriterien:**
|
||||
1. ✅ Intermediate-Zertifikate (nur Leaf)
|
||||
2. ✅ Nur `certigo-acmeproxy` Provider
|
||||
3. ✅ Status = 'issued'
|
||||
4. ✅ Auto-Renewal aktiviert (Certificate + FQDN)
|
||||
5. ✅ `expires_at` innerhalb Threshold
|
||||
6. ✅ Keine laufende Erneuerung (`renewal_status != 'in_progress'`)
|
||||
7. ✅ Max. Versuche nicht überschritten
|
||||
8. ✅ Retry-Delay eingehalten
|
||||
|
||||
---
|
||||
|
||||
## 7. Renewal Worker
|
||||
|
||||
### 7.1 Renewal-Prozess
|
||||
|
||||
```go
|
||||
func renewCertificate(certID string, fqdnID string, spaceID string) error {
|
||||
traceID := generateTraceID()
|
||||
|
||||
// 1. Markiere als "in_progress"
|
||||
updateRenewalStatus(certID, "in_progress", traceID)
|
||||
|
||||
// 2. Hole FQDN-Informationen
|
||||
fqdn, err := getFQDN(fqdnID)
|
||||
if err != nil {
|
||||
logRenewalError(certID, traceID, "FQDN nicht gefunden", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Prüfe DNS (CNAME)
|
||||
if !validateDNSCNAME(fqdn.FQDN) {
|
||||
logRenewalError(certID, traceID, "DNS-CNAME nicht gültig", nil)
|
||||
return fmt.Errorf("DNS validation failed")
|
||||
}
|
||||
|
||||
// 4. Hole ACME-Provider-Konfiguration
|
||||
providerConfig, err := getACMEProviderConfig(fqdn.ProviderID)
|
||||
if err != nil {
|
||||
logRenewalError(certID, traceID, "Provider-Konfiguration nicht gefunden", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. Rufe RequestCertificate() auf
|
||||
result, err := RequestCertificate(
|
||||
fqdn.FQDN,
|
||||
fqdn.AcmeEmail,
|
||||
fqdnID,
|
||||
fqdn.AcmeKeyID,
|
||||
traceID,
|
||||
updateTokenFunc,
|
||||
cleanupTokenFunc,
|
||||
statusCallback,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
// 6a. Fehler: Erhöhe Versuche, setze Retry-Zeitpunkt
|
||||
incrementRenewalAttempts(certID)
|
||||
setNextRenewalCheck(certID, time.Now().Add(retryDelay))
|
||||
updateRenewalStatus(certID, "failed", traceID)
|
||||
logRenewalError(certID, traceID, "Erneuerung fehlgeschlagen", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 6b. Erfolg: Speichere neues Zertifikat
|
||||
newCertID, err := saveNewCertificate(result, fqdnID, spaceID)
|
||||
if err != nil {
|
||||
logRenewalError(certID, traceID, "Fehler beim Speichern", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// 7. Verknüpfe alte und neue Zertifikate
|
||||
linkCertificates(certID, newCertID)
|
||||
|
||||
// 8. Markiere als erfolgreich
|
||||
updateRenewalStatus(certID, "success", traceID)
|
||||
logRenewalSuccess(certID, newCertID, traceID)
|
||||
|
||||
// 9. Optional: Benachrichtigung senden
|
||||
sendRenewalNotification(fqdn.FQDN, newCertID, traceID)
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Concurrency Control
|
||||
|
||||
**Sequenzielle Verarbeitung:**
|
||||
- Pro FQDN nur eine Erneuerung gleichzeitig
|
||||
- Pro Space max. N Erneuerungen parallel (konfigurierbar)
|
||||
- Global max. M Erneuerungen parallel (konfigurierbar)
|
||||
|
||||
**Implementierung:**
|
||||
```go
|
||||
// Semaphore für Concurrency Control
|
||||
var renewalSemaphore = make(chan struct{}, maxConcurrentRenewals)
|
||||
|
||||
func renewCertificateWithLock(certID string, fqdnID string, spaceID string) error {
|
||||
renewalSemaphore <- struct{}{} // Acquire
|
||||
defer func() { <-renewalSemaphore }() // Release
|
||||
|
||||
return renewCertificate(certID, fqdnID, spaceID)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Fehlerbehandlung & Retry
|
||||
|
||||
### 8.1 Fehler-Kategorien
|
||||
|
||||
| Fehler-Typ | Retry? | Max. Versuche | Beispiel |
|
||||
|-----------|--------|--------------|----------|
|
||||
| DNS-Validierung fehlgeschlagen | ✅ Ja | 3 | CNAME nicht gesetzt |
|
||||
| ACME-Provider-Fehler | ✅ Ja | 3 | Rate Limit erreicht |
|
||||
| Netzwerk-Fehler | ✅ Ja | 5 | Timeout, Connection Error |
|
||||
| Konfigurations-Fehler | ❌ Nein | 0 | Provider nicht konfiguriert |
|
||||
| Berechtigungs-Fehler | ❌ Nein | 0 | Kein Space-Zugriff |
|
||||
|
||||
### 8.2 Retry-Strategie
|
||||
|
||||
**Exponential Backoff:**
|
||||
```
|
||||
Versuch 1: Sofort
|
||||
Versuch 2: Nach 24 Stunden
|
||||
Versuch 3: Nach 48 Stunden
|
||||
Versuch 4+: Nach 72 Stunden
|
||||
```
|
||||
|
||||
**Oder: Fixed Delay**
|
||||
```
|
||||
Alle Retries: Nach X Stunden (konfigurierbar, Default: 24h)
|
||||
```
|
||||
|
||||
### 8.3 Fehler-Logging
|
||||
|
||||
```go
|
||||
type RenewalError struct {
|
||||
CertificateID string
|
||||
FQDN string
|
||||
ErrorType string // 'dns', 'acme', 'network', 'config'
|
||||
ErrorMessage string
|
||||
TraceID string
|
||||
Timestamp time.Time
|
||||
Attempt int
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Logging & Monitoring
|
||||
|
||||
### 9.1 Structured Logging
|
||||
|
||||
**Erfolgreiche Erneuerung:**
|
||||
```json
|
||||
{
|
||||
"event": "certificate_renewal_success",
|
||||
"trace_id": "abc123",
|
||||
"certificate_id": "cert-uuid",
|
||||
"fqdn": "example.com",
|
||||
"old_expires_at": "2025-02-15T10:00:00Z",
|
||||
"new_expires_at": "2025-05-15T10:00:00Z",
|
||||
"new_certificate_id": "new-cert-uuid",
|
||||
"duration_seconds": 45,
|
||||
"timestamp": "2025-01-15T02:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Fehlgeschlagene Erneuerung:**
|
||||
```json
|
||||
{
|
||||
"event": "certificate_renewal_failed",
|
||||
"trace_id": "abc123",
|
||||
"certificate_id": "cert-uuid",
|
||||
"fqdn": "example.com",
|
||||
"error_type": "dns_validation",
|
||||
"error_message": "CNAME record not found",
|
||||
"attempt": 1,
|
||||
"max_attempts": 3,
|
||||
"next_retry": "2025-01-16T02:00:00Z",
|
||||
"timestamp": "2025-01-15T02:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Audit Logs
|
||||
|
||||
**Integration in bestehendes Audit-System:**
|
||||
```go
|
||||
auditService.Track(ctx, "RENEW", "certificate", certID, "system", "auto-renewal", map[string]interface{}{
|
||||
"fqdn": fqdn,
|
||||
"old_expires_at": oldExpiresAt,
|
||||
"new_expires_at": newExpiresAt,
|
||||
"trace_id": traceID,
|
||||
}, ipAddress, userAgent)
|
||||
```
|
||||
|
||||
### 9.3 Metrics
|
||||
|
||||
**Zu tracken:**
|
||||
- Anzahl Erneuerungen pro Tag/Woche/Monat
|
||||
- Erfolgsrate (Erfolgreich / Gesamt)
|
||||
- Durchschnittliche Erneuerungsdauer
|
||||
- Anzahl fehlgeschlagener Erneuerungen
|
||||
- Anzahl Retries
|
||||
- Zertifikate die bald ablaufen (Warnung)
|
||||
|
||||
---
|
||||
|
||||
## 10. API-Endpunkte
|
||||
|
||||
### 10.1 Manuelle Erneuerung
|
||||
|
||||
**POST** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renew`
|
||||
|
||||
Manuell eine Erneuerung auslösen.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Erneuerung gestartet",
|
||||
"trace_id": "abc123",
|
||||
"estimated_completion": "2025-01-15T02:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 Erneuerungs-Status abrufen
|
||||
|
||||
**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-status`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"auto_renewal_enabled": true,
|
||||
"renewal_status": "success",
|
||||
"renewal_attempts": 1,
|
||||
"last_renewal_attempt": "2025-01-15T02:00:00Z",
|
||||
"next_renewal_check": "2025-01-16T02:00:00Z",
|
||||
"replaced_by_cert_id": "new-cert-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 Erneuerungs-Logs abrufen
|
||||
|
||||
**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-logs`
|
||||
|
||||
**Query Parameters:**
|
||||
- `limit` (optional): Anzahl Einträge (Default: 50)
|
||||
- `offset` (optional): Pagination Offset
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"logs": [
|
||||
{
|
||||
"id": "log-uuid",
|
||||
"renewal_status": "success",
|
||||
"renewal_reason": "expiring_soon",
|
||||
"old_expires_at": "2025-02-15T10:00:00Z",
|
||||
"new_expires_at": "2025-05-15T10:00:00Z",
|
||||
"new_certificate_id": "new-cert-uuid",
|
||||
"renewal_duration_seconds": 45,
|
||||
"trace_id": "abc123",
|
||||
"created_at": "2025-01-15T02:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10,
|
||||
"limit": 50,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 10.4 Auto-Renewal konfigurieren
|
||||
|
||||
**PUT** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/auto-renewal`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### 10.5 Global Configuration
|
||||
|
||||
**GET** `/api/internal/renewal/config`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"renewal_threshold_days": 30,
|
||||
"check_interval_hours": 24,
|
||||
"max_renewal_attempts": 3,
|
||||
"retry_delay_hours": 24
|
||||
}
|
||||
```
|
||||
|
||||
**PUT** `/api/internal/renewal/config`
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"renewal_threshold_days": 30,
|
||||
"check_interval_hours": 24,
|
||||
"max_renewal_attempts": 3,
|
||||
"retry_delay_hours": 24
|
||||
}
|
||||
```
|
||||
|
||||
### 10.6 Manueller Scan (für Testing)
|
||||
|
||||
**POST** `/api/internal/renewal/scan`
|
||||
|
||||
Löst einen manuellen Scan aus (nur für Admins).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"certificates_found": 5,
|
||||
"certificates_queued": 3,
|
||||
"certificates_skipped": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Frontend-Integration
|
||||
|
||||
### 11.1 UI-Komponenten
|
||||
|
||||
#### Auto-Renewal Toggle
|
||||
- **Ort**: Certificate Detail View
|
||||
- **Funktion**: Ein/Aus-Schalter für Auto-Renewal pro Zertifikat
|
||||
|
||||
#### Renewal Status Badge
|
||||
- **Ort**: Certificate List & Detail View
|
||||
- **Anzeige**:
|
||||
- 🟢 "Auto-Renewal aktiv" (wenn enabled)
|
||||
- 🟡 "Erneuerung läuft" (wenn in_progress)
|
||||
- 🔴 "Erneuerung fehlgeschlagen" (wenn failed)
|
||||
- ⚪ "Auto-Renewal deaktiviert" (wenn disabled)
|
||||
|
||||
#### Renewal History
|
||||
- **Ort**: Certificate Detail View
|
||||
- **Anzeige**: Tabelle mit Erneuerungs-Logs
|
||||
- **Spalten**: Datum, Status, Grund, Neue Ablaufzeit, Trace ID
|
||||
|
||||
#### Manuelle Erneuerung Button
|
||||
- **Ort**: Certificate Detail View
|
||||
- **Funktion**: "Jetzt erneuern" Button (falls Auto-Renewal deaktiviert)
|
||||
|
||||
#### Upcoming Renewals Dashboard
|
||||
- **Ort**: Dashboard/Overview
|
||||
- **Anzeige**: Liste von Zertifikaten die bald erneuert werden
|
||||
- **Filter**: Nach Space, FQDN, Ablaufdatum
|
||||
|
||||
### 11.2 Notifications (Optional)
|
||||
|
||||
**Email-Benachrichtigungen:**
|
||||
- Erfolgreiche Erneuerung
|
||||
- Fehlgeschlagene Erneuerung (nach max. Versuchen)
|
||||
- Warnung: Zertifikat läuft in X Tagen ab (falls Erneuerung fehlschlägt)
|
||||
|
||||
**In-App Notifications:**
|
||||
- Toast-Notification bei erfolgreicher Erneuerung
|
||||
- Alert bei fehlgeschlagener Erneuerung
|
||||
|
||||
---
|
||||
|
||||
## 12. Sicherheit & Berechtigungen
|
||||
|
||||
### 12.1 Berechtigungen
|
||||
|
||||
**Auto-Renewal ausführen:**
|
||||
- System-User (für automatische Erneuerungen)
|
||||
- Admin-User (für manuelle Erneuerungen)
|
||||
- User mit `FULL_ACCESS` auf Space (für manuelle Erneuerungen)
|
||||
|
||||
**Auto-Renewal konfigurieren:**
|
||||
- Admin-User
|
||||
- User mit `FULL_ACCESS` auf Space
|
||||
|
||||
**Erneuerungs-Logs anzeigen:**
|
||||
- Alle User mit Space-Zugriff (READ-Berechtigung)
|
||||
|
||||
### 12.2 Rate Limiting
|
||||
|
||||
**Let's Encrypt Rate Limits:**
|
||||
- 50 Certificates per Registered Domain per week
|
||||
- 300 New Orders per Account per 3 hours
|
||||
|
||||
**Schutz:**
|
||||
- Tracke Anzahl Erneuerungen pro FQDN
|
||||
- Verzögere Erneuerung wenn Rate Limit erreicht
|
||||
- Logge Warnung bei Rate Limit
|
||||
|
||||
---
|
||||
|
||||
## 13. Testing & Rollout
|
||||
|
||||
### 13.1 Test-Plan
|
||||
|
||||
**Phase 1: Unit Tests**
|
||||
- [ ] Certificate Scanner Query
|
||||
- [ ] Renewal Worker Logic
|
||||
- [ ] Retry-Mechanismus
|
||||
- [ ] Error-Handling
|
||||
|
||||
**Phase 2: Integration Tests**
|
||||
- [ ] End-to-End Erneuerung (mit Staging ACME)
|
||||
- [ ] Fehler-Szenarien (DNS-Fehler, Rate Limit)
|
||||
- [ ] Concurrency Tests
|
||||
|
||||
**Phase 3: Staging Tests**
|
||||
- [ ] Test mit echten Staging-Zertifikaten
|
||||
- [ ] Monitoring & Logging prüfen
|
||||
- [ ] Performance-Tests
|
||||
|
||||
**Phase 4: Production Rollout**
|
||||
- [ ] Feature Flag aktivieren
|
||||
- [ ] Monitoring aktivieren
|
||||
- [ ] Schrittweise Aktivierung (zuerst einzelne FQDNs)
|
||||
|
||||
### 13.2 Rollback-Plan
|
||||
|
||||
**Falls Probleme auftreten:**
|
||||
1. Auto-Renewal global deaktivieren (Config)
|
||||
2. Laufende Erneuerungen abbrechen (Status zurücksetzen)
|
||||
3. Manuelle Erneuerung weiterhin möglich
|
||||
|
||||
---
|
||||
|
||||
## 14. Monitoring & Alerting
|
||||
|
||||
### 14.1 Health Checks
|
||||
|
||||
**Endpoint:** `GET /api/health/renewal`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"last_scan": "2025-01-15T02:00:00Z",
|
||||
"next_scan": "2025-01-16T02:00:00Z",
|
||||
"certificates_pending": 2,
|
||||
"certificates_in_progress": 1,
|
||||
"certificates_failed": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 14.2 Alerts
|
||||
|
||||
**Zu überwachen:**
|
||||
- ❌ Auto-Renewal Service läuft nicht
|
||||
- ⚠️ Viele fehlgeschlagene Erneuerungen (> 10% in 24h)
|
||||
- ⚠️ Zertifikate laufen in < 7 Tagen ab (ohne Erneuerung)
|
||||
- ⚠️ Rate Limit erreicht
|
||||
- ⚠️ Scheduler läuft nicht (letzter Scan > 48h her)
|
||||
|
||||
---
|
||||
|
||||
## 15. Zukünftige Erweiterungen
|
||||
|
||||
### 15.1 Multi-Provider Support
|
||||
- Erneuerung für andere Provider (nicht nur Let's Encrypt)
|
||||
|
||||
### 15.2 Smart Scheduling
|
||||
- Erneuerung basierend auf Traffic-Patterns
|
||||
- Erneuerung außerhalb der Geschäftszeiten
|
||||
|
||||
### 15.3 Batch Renewals
|
||||
- Erneuerung mehrerer Zertifikate gleichzeitig (wenn möglich)
|
||||
|
||||
### 15.4 Webhook-Integration
|
||||
- Webhooks für erfolgreiche/fehlgeschlagene Erneuerungen
|
||||
- Integration mit externen Monitoring-Tools
|
||||
|
||||
### 15.5 Certificate Rotation
|
||||
- Automatische Rotation von Private Keys
|
||||
- Unterstützung für Key-Rollover
|
||||
|
||||
---
|
||||
|
||||
## 16. Abhängigkeiten
|
||||
|
||||
### 16.1 Backend (Go)
|
||||
|
||||
```go
|
||||
// Cron Scheduler
|
||||
github.com/robfig/cron/v3
|
||||
|
||||
// (Bereits vorhanden)
|
||||
// - ACME Client (acme_client.go)
|
||||
// - Certificate Parser (cert_parser.go)
|
||||
// - Logger (cert_logger.go)
|
||||
```
|
||||
|
||||
### 16.2 Frontend
|
||||
|
||||
Keine zusätzlichen Dependencies nötig.
|
||||
|
||||
---
|
||||
|
||||
## 17. Risiken & Mitigation
|
||||
|
||||
### 17.1 Risiken
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|
||||
|--------|-------------------|--------|------------|
|
||||
| Rate Limit erreicht | Mittel | Hoch | Rate Limit Tracking, Verzögerung |
|
||||
| DNS-Validierung fehlschlägt | Mittel | Hoch | DNS-Check vor Erneuerung, Retry |
|
||||
| ACME-Provider Downtime | Niedrig | Hoch | Retry-Mechanismus, Fallback |
|
||||
| Doppelte Erneuerung | Niedrig | Mittel | Status-Check, Locking |
|
||||
| Datenbank-Lock | Niedrig | Mittel | Transaktionen, Timeouts |
|
||||
|
||||
### 17.2 Best Practices
|
||||
|
||||
- ✅ Idempotenz: Erneuerung kann mehrfach ausgeführt werden ohne Probleme
|
||||
- ✅ Atomic Operations: Datenbank-Transaktionen für Konsistenz
|
||||
- ✅ Graceful Degradation: Bei Fehlern weiterhin manuelle Erneuerung möglich
|
||||
- ✅ Comprehensive Logging: Alle Schritte loggen für Debugging
|
||||
- ✅ Rate Limit Awareness: Respektiere Let's Encrypt Limits
|
||||
|
||||
---
|
||||
|
||||
## 18. Zusammenfassung
|
||||
|
||||
### 18.1 Vorteile
|
||||
|
||||
- **Automatisierung**: Keine manuelle Intervention nötig
|
||||
- **Zuverlässigkeit**: Zertifikate laufen nicht mehr ab
|
||||
- **Zeitersparnis**: Weniger manuelle Arbeit
|
||||
- **Sicherheit**: Immer gültige Zertifikate
|
||||
|
||||
### 18.2 Herausforderungen
|
||||
|
||||
- **Komplexität**: Zusätzliche Infrastruktur und Code
|
||||
- **Fehlerbehandlung**: Robustes Error-Handling erforderlich
|
||||
- **Rate Limits**: Let's Encrypt Limits beachten
|
||||
- **Testing**: Umfangreiche Tests erforderlich
|
||||
|
||||
### 18.3 Empfohlene Implementierungs-Reihenfolge
|
||||
|
||||
1. **Phase 1**: Datenbank-Schema & Grundfunktionalität
|
||||
2. **Phase 2**: Scanner & Worker
|
||||
3. **Phase 3**: Scheduler & Automation
|
||||
4. **Phase 4**: Frontend-Integration
|
||||
5. **Phase 5**: Monitoring & Alerting
|
||||
6. **Phase 6**: Notifications (Optional)
|
||||
|
||||
---
|
||||
|
||||
**Erstellt am**: 2025-01-XX
|
||||
**Version**: 1.0
|
||||
**Status**: Konzept - Noch nicht implementiert
|
||||
|
||||
754
docs/OAUTH_KONZEPT.md
Normal file
754
docs/OAUTH_KONZEPT.md
Normal file
@@ -0,0 +1,754 @@
|
||||
# OAuth 2.0 Integration Konzept für Certigo
|
||||
|
||||
## 1. Übersicht
|
||||
|
||||
### 1.1 Ziel
|
||||
Integration von OAuth 2.0 als zusätzliche Authentifizierungsmethode neben dem bestehenden Basic Authentication System. Benutzer sollen sich mit externen OAuth-Providern (z.B. Google, Microsoft, GitHub) anmelden können.
|
||||
|
||||
### 1.2 Anforderungen
|
||||
- **Hybrides System**: OAuth und Basic Auth parallel unterstützen
|
||||
- **User Linking**: OAuth-Benutzer mit bestehenden lokalen Accounts verknüpfen können
|
||||
- **Automatische User-Erstellung**: Neue OAuth-Benutzer automatisch anlegen
|
||||
- **Berechtigungssystem**: OAuth-Benutzer in bestehendes Permission-System integrieren
|
||||
- **Session Management**: Sichere Session-Verwaltung für OAuth-Logins
|
||||
- **Multi-Provider**: Unterstützung mehrerer OAuth-Provider gleichzeitig
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur
|
||||
|
||||
### 2.1 OAuth Flow (Authorization Code Flow)
|
||||
|
||||
```
|
||||
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
|
||||
│ Browser │ │ Frontend │ │ Backend │ │ OAuth │
|
||||
│ │ │ │ │ │ │ Provider │
|
||||
└────┬────┘ └────┬─────┘ └──────┬──────┘ └────┬─────┘
|
||||
│ │ │ │
|
||||
│ 1. Login Button │ │ │
|
||||
│──────────────────>│ │ │
|
||||
│ │ │ │
|
||||
│ │ 2. GET /api/oauth/{provider}/auth │
|
||||
│ │─────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ │ 3. Redirect to OAuth │
|
||||
│ │ │ Authorization URL │
|
||||
│ │<─────────────────────│ │
|
||||
│ │ │ │
|
||||
│ 4. Redirect to │ │ │
|
||||
│ OAuth Provider │ │ │
|
||||
│<──────────────────│ │ │
|
||||
│ │ │ │
|
||||
│ 5. User Auth │ │ │
|
||||
│────────────────────────────────────────────────────────────────>│
|
||||
│ │ │ │
|
||||
│ 6. Callback with │ │ │
|
||||
│ Authorization │ │ │
|
||||
│ Code │ │ │
|
||||
│<────────────────────────────────────────────────────────────────│
|
||||
│ │ │ │
|
||||
│ 7. Callback to │ │ │
|
||||
│ /api/oauth/ │ │ │
|
||||
│ {provider}/ │ │ │
|
||||
│ callback │ │ │
|
||||
│──────────────────>│ │ │
|
||||
│ │ 8. POST /api/oauth/{provider}/callback │
|
||||
│ │ (code=xxx) │ │
|
||||
│ │─────────────────────>│ │
|
||||
│ │ │ │
|
||||
│ │ │ 9. Exchange Code for │
|
||||
│ │ │ Access Token │
|
||||
│ │ │──────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ 10. Get User Info │
|
||||
│ │ │──────────────────────>│
|
||||
│ │ │ │
|
||||
│ │ │ 11. User Info │
|
||||
│ │ │<──────────────────────│
|
||||
│ │ │ │
|
||||
│ │ │ 12. Create/Update │
|
||||
│ │ │ User in DB │
|
||||
│ │ │ │
|
||||
│ │ │ 13. Create Session │
|
||||
│ │ │ │
|
||||
│ │ 14. Return Session │ │
|
||||
│ │ Token │ │
|
||||
│ │<─────────────────────│ │
|
||||
│ │ │ │
|
||||
│ 15. Store Session │ │ │
|
||||
│ & Redirect │ │ │
|
||||
│<──────────────────│ │ │
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
### 2.2 Komponenten
|
||||
|
||||
#### Backend
|
||||
- **OAuth Handler**: `/api/oauth/{provider}/auth` - Initiierung des OAuth Flows
|
||||
- **OAuth Callback Handler**: `/api/oauth/{provider}/callback` - Verarbeitung des Authorization Codes
|
||||
- **OAuth Provider Manager**: Verwaltung mehrerer OAuth-Provider
|
||||
- **Session Manager**: Verwaltung von OAuth-Sessions (JWT oder Session-Tokens)
|
||||
- **User Linking Service**: Verknüpfung von OAuth-Accounts mit lokalen Accounts
|
||||
|
||||
#### Frontend
|
||||
- **OAuth Login Component**: Buttons für verschiedene OAuth-Provider
|
||||
- **OAuth Callback Handler**: Verarbeitung des Redirects nach OAuth-Authentifizierung
|
||||
- **Session Storage**: Speicherung von Session-Tokens (HttpOnly Cookies bevorzugt)
|
||||
|
||||
---
|
||||
|
||||
## 3. Datenbank-Schema
|
||||
|
||||
### 3.1 Erweiterte Users-Tabelle
|
||||
|
||||
```sql
|
||||
-- Migration: Erweitere users-Tabelle um OAuth-Felder
|
||||
ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'basic'; -- 'basic' | 'oauth' | 'hybrid'
|
||||
ALTER TABLE users ADD COLUMN oauth_provider TEXT; -- 'google' | 'microsoft' | 'github' | NULL
|
||||
ALTER TABLE users ADD COLUMN oauth_provider_id TEXT; -- Externe User-ID vom OAuth-Provider
|
||||
ALTER TABLE users ADD COLUMN oauth_email TEXT; -- Email vom OAuth-Provider (kann von lokaler Email abweichen)
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT; -- NULL für reine OAuth-User
|
||||
```
|
||||
|
||||
### 3.2 Neue Tabelle: oauth_sessions
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
access_token TEXT, -- Verschlüsselt gespeichert
|
||||
refresh_token TEXT, -- Verschlüsselt gespeichert
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
last_used_at DATETIME,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_sessions_user_id ON oauth_sessions(user_id);
|
||||
CREATE INDEX idx_oauth_sessions_expires_at ON oauth_sessions(expires_at);
|
||||
```
|
||||
|
||||
### 3.3 Neue Tabelle: oauth_providers
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS oauth_providers (
|
||||
id TEXT PRIMARY KEY, -- 'google', 'microsoft', 'github'
|
||||
name TEXT NOT NULL,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
client_id TEXT NOT NULL,
|
||||
client_secret TEXT NOT NULL, -- Verschlüsselt gespeichert
|
||||
authorization_url TEXT NOT NULL,
|
||||
token_url TEXT NOT NULL,
|
||||
user_info_url TEXT NOT NULL,
|
||||
scopes TEXT, -- JSON Array: ["openid", "email", "profile"]
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### 3.4 Neue Tabelle: user_oauth_links
|
||||
|
||||
```sql
|
||||
-- Für Benutzer, die mehrere OAuth-Provider verknüpfen wollen
|
||||
CREATE TABLE IF NOT EXISTS user_oauth_links (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
provider_user_id TEXT NOT NULL,
|
||||
provider_email TEXT,
|
||||
linked_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(provider, provider_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_oauth_links_user_id ON user_oauth_links(user_id);
|
||||
CREATE INDEX idx_user_oauth_links_provider ON user_oauth_links(provider, provider_user_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. OAuth Provider Konfiguration
|
||||
|
||||
### 4.1 Unterstützte Provider
|
||||
|
||||
#### Google OAuth 2.0
|
||||
- **Authorization URL**: `https://accounts.google.com/o/oauth2/v2/auth`
|
||||
- **Token URL**: `https://oauth2.googleapis.com/token`
|
||||
- **User Info URL**: `https://www.googleapis.com/oauth2/v2/userinfo`
|
||||
- **Scopes**: `["openid", "email", "profile"]`
|
||||
|
||||
#### Microsoft Azure AD
|
||||
- **Authorization URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize`
|
||||
- **Token URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`
|
||||
- **User Info URL**: `https://graph.microsoft.com/v1.0/me`
|
||||
- **Scopes**: `["openid", "email", "profile"]`
|
||||
|
||||
#### GitHub
|
||||
- **Authorization URL**: `https://github.com/login/oauth/authorize`
|
||||
- **Token URL**: `https://github.com/login/oauth/access_token`
|
||||
- **User Info URL**: `https://api.github.com/user`
|
||||
- **Scopes**: `["user:email"]`
|
||||
|
||||
### 4.2 Provider-Konfiguration (Environment Variables / Config File)
|
||||
|
||||
```yaml
|
||||
oauth:
|
||||
providers:
|
||||
google:
|
||||
enabled: true
|
||||
client_id: "${GOOGLE_CLIENT_ID}"
|
||||
client_secret: "${GOOGLE_CLIENT_SECRET}"
|
||||
redirect_uri: "http://localhost:5173/api/oauth/google/callback"
|
||||
scopes: ["openid", "email", "profile"]
|
||||
microsoft:
|
||||
enabled: true
|
||||
tenant: "${MICROSOFT_TENANT_ID}"
|
||||
client_id: "${MICROSOFT_CLIENT_ID}"
|
||||
client_secret: "${MICROSOFT_CLIENT_SECRET}"
|
||||
redirect_uri: "http://localhost:5173/api/oauth/microsoft/callback"
|
||||
scopes: ["openid", "email", "profile"]
|
||||
github:
|
||||
enabled: false
|
||||
client_id: "${GITHUB_CLIENT_ID}"
|
||||
client_secret: "${GITHUB_CLIENT_SECRET}"
|
||||
redirect_uri: "http://localhost:5173/api/oauth/github/callback"
|
||||
scopes: ["user:email"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API-Endpunkte
|
||||
|
||||
### 5.1 OAuth Initiation
|
||||
|
||||
**GET** `/api/oauth/{provider}/auth`
|
||||
|
||||
Initiert den OAuth Flow für einen bestimmten Provider.
|
||||
|
||||
**Query Parameters:**
|
||||
- `redirect_uri` (optional): Custom Redirect URI nach erfolgreichem Login
|
||||
|
||||
**Response:**
|
||||
- `302 Redirect` zur OAuth Provider Authorization URL
|
||||
|
||||
**Beispiel:**
|
||||
```
|
||||
GET /api/oauth/google/auth
|
||||
→ Redirect zu: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&scope=...&response_type=code&state=...
|
||||
```
|
||||
|
||||
### 5.2 OAuth Callback
|
||||
|
||||
**GET** `/api/oauth/{provider}/callback`
|
||||
|
||||
Verarbeitet den Authorization Code vom OAuth-Provider.
|
||||
|
||||
**Query Parameters:**
|
||||
- `code`: Authorization Code vom Provider
|
||||
- `state`: CSRF Protection Token (optional, aber empfohlen)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"username": "user@example.com",
|
||||
"email": "user@example.com",
|
||||
"isAdmin": false,
|
||||
"enabled": true,
|
||||
"authMethod": "oauth",
|
||||
"oauthProvider": "google"
|
||||
},
|
||||
"sessionToken": "jwt-token-here",
|
||||
"redirectTo": "/"
|
||||
}
|
||||
```
|
||||
|
||||
**Fehler-Response:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Invalid authorization code",
|
||||
"errorCode": "OAUTH_INVALID_CODE"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 OAuth Logout
|
||||
|
||||
**POST** `/api/oauth/logout`
|
||||
|
||||
Beendet die OAuth-Session.
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Bearer {sessionToken}`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Logged out successfully"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 OAuth Provider Status
|
||||
|
||||
**GET** `/api/oauth/providers`
|
||||
|
||||
Gibt eine Liste aller konfigurierten OAuth-Provider zurück.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"id": "google",
|
||||
"name": "Google",
|
||||
"enabled": true,
|
||||
"authUrl": "/api/oauth/google/auth"
|
||||
},
|
||||
{
|
||||
"id": "microsoft",
|
||||
"name": "Microsoft",
|
||||
"enabled": true,
|
||||
"authUrl": "/api/oauth/microsoft/auth"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Link OAuth Account
|
||||
|
||||
**POST** `/api/oauth/link`
|
||||
|
||||
Verknüpft einen OAuth-Account mit einem bestehenden lokalen Account (für Hybrid-Auth).
|
||||
|
||||
**Headers:**
|
||||
- `Authorization: Basic {credentials}` (lokaler Account)
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"provider": "google",
|
||||
"code": "authorization-code-from-oauth"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OAuth account linked successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Session Management
|
||||
|
||||
### 6.1 Session-Token (JWT)
|
||||
|
||||
**Token-Struktur:**
|
||||
```json
|
||||
{
|
||||
"sub": "user-uuid",
|
||||
"auth_method": "oauth",
|
||||
"provider": "google",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567890,
|
||||
"session_id": "session-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**Token-Speicherung:**
|
||||
- **Backend**: In `oauth_sessions` Tabelle
|
||||
- **Frontend**: HttpOnly Cookie (bevorzugt) oder localStorage (Fallback)
|
||||
- **Lifetime**: 24 Stunden (konfigurierbar)
|
||||
- **Refresh**: Automatisches Refresh bei Ablauf (falls Refresh Token vorhanden)
|
||||
|
||||
### 6.2 Session-Validierung
|
||||
|
||||
**Middleware**: `oauthSessionMiddleware`
|
||||
|
||||
Prüft OAuth-Session-Token in folgenden Headers:
|
||||
1. `Authorization: Bearer {token}`
|
||||
2. Cookie: `oauth_session`
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Request → oauthSessionMiddleware → Check Token → Validate Session → Continue
|
||||
↓ Invalid
|
||||
401 Unauthorized
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. User Management
|
||||
|
||||
### 7.1 Automatische User-Erstellung
|
||||
|
||||
**Flow:**
|
||||
1. OAuth-Callback empfängt User-Info vom Provider
|
||||
2. Prüfe ob User mit `oauth_provider_id` existiert
|
||||
3. Falls nicht:
|
||||
- Erstelle neuen User
|
||||
- Setze `auth_method = 'oauth'`
|
||||
- Setze `oauth_provider` und `oauth_provider_id`
|
||||
- Setze `password_hash = NULL`
|
||||
- Setze `enabled = true` (oder konfigurierbar)
|
||||
- Setze `isAdmin = false`
|
||||
- Weise Standard-Berechtigungsgruppe zu (optional)
|
||||
4. Falls ja:
|
||||
- Update `last_login_at` (falls Feld existiert)
|
||||
- Update Session
|
||||
|
||||
### 7.2 User Linking (Hybrid Auth)
|
||||
|
||||
**Szenario**: Benutzer hat bereits lokalen Account, möchte OAuth hinzufügen
|
||||
|
||||
**Flow:**
|
||||
1. User loggt sich mit Basic Auth ein
|
||||
2. User klickt "Link Google Account"
|
||||
3. OAuth Flow wird initiiert
|
||||
4. Nach erfolgreicher OAuth-Auth:
|
||||
- Verknüpfe OAuth-Account mit lokalem Account
|
||||
- Setze `auth_method = 'hybrid'`
|
||||
- Erstelle Eintrag in `user_oauth_links`
|
||||
5. User kann sich nun mit beiden Methoden anmelden
|
||||
|
||||
### 7.3 User Mapping
|
||||
|
||||
**Email-basierte Verknüpfung:**
|
||||
- Falls OAuth-Email mit lokalem Account übereinstimmt → Auto-Link (optional, konfigurierbar)
|
||||
- Falls nicht → Neue User-Erstellung oder manuelle Verknüpfung erforderlich
|
||||
|
||||
---
|
||||
|
||||
## 8. Sicherheit
|
||||
|
||||
### 8.1 CSRF Protection
|
||||
|
||||
**State Parameter:**
|
||||
- Generiere zufälligen `state` Token bei OAuth-Initiation
|
||||
- Speichere in Session/Cookie
|
||||
- Validiere bei Callback
|
||||
|
||||
**Implementierung:**
|
||||
```go
|
||||
state := generateRandomToken(32)
|
||||
storeStateInSession(state)
|
||||
redirectURL := fmt.Sprintf("%s?state=%s&...", oauthURL, state)
|
||||
```
|
||||
|
||||
### 8.2 Token-Verschlüsselung
|
||||
|
||||
**Access/Refresh Tokens:**
|
||||
- Verschlüsselt in Datenbank speichern (AES-256)
|
||||
- Nie im Klartext loggen
|
||||
- Automatische Löschung bei Ablauf
|
||||
|
||||
### 8.3 Rate Limiting
|
||||
|
||||
**OAuth-Endpunkte:**
|
||||
- `/api/oauth/{provider}/auth`: 10 Requests/Minute pro IP
|
||||
- `/api/oauth/{provider}/callback`: 5 Requests/Minute pro IP
|
||||
|
||||
### 8.4 Secure Cookies
|
||||
|
||||
**Session Cookies:**
|
||||
- `HttpOnly`: true
|
||||
- `Secure`: true (HTTPS only)
|
||||
- `SameSite`: Lax oder Strict
|
||||
- `Path`: `/api`
|
||||
|
||||
---
|
||||
|
||||
## 9. Frontend-Integration
|
||||
|
||||
### 9.1 Login-Seite Erweiterung
|
||||
|
||||
**Aktuelle Login-Seite** (`frontend/src/pages/Login.jsx`) erweitern:
|
||||
|
||||
```jsx
|
||||
// OAuth Login Buttons hinzufügen
|
||||
<div className="oauth-providers">
|
||||
<button onClick={() => initiateOAuth('google')}>
|
||||
<img src="/icons/google.svg" /> Mit Google anmelden
|
||||
</button>
|
||||
<button onClick={() => initiateOAuth('microsoft')}>
|
||||
<img src="/icons/microsoft.svg" /> Mit Microsoft anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Oder: Separater OAuth-Login-Bereich
|
||||
<div className="divider">oder</div>
|
||||
```
|
||||
|
||||
### 9.2 OAuth Callback Handler
|
||||
|
||||
**Neue Route**: `frontend/src/pages/OAuthCallback.jsx`
|
||||
|
||||
```jsx
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
const provider = extractProviderFromURL() // z.B. '/oauth/google/callback'
|
||||
|
||||
if (code) {
|
||||
handleOAuthCallback(provider, code, state)
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
### 9.3 AuthContext Erweiterung
|
||||
|
||||
**Erweitere** `frontend/src/contexts/AuthContext.jsx`:
|
||||
|
||||
```jsx
|
||||
const loginWithOAuth = async (provider) => {
|
||||
// Redirect zu Backend OAuth Initiation
|
||||
window.location.href = `/api/oauth/${provider}/auth`
|
||||
}
|
||||
|
||||
const handleOAuthCallback = async (provider, code, state) => {
|
||||
const response = await fetch(`/api/oauth/${provider}/callback?code=${code}&state=${state}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
// Store session token
|
||||
localStorage.setItem('oauth_session', data.sessionToken)
|
||||
setUser(data.user)
|
||||
setIsAuthenticated(true)
|
||||
navigate('/')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration & Backward Compatibility
|
||||
|
||||
### 10.1 Bestehende User
|
||||
|
||||
**Migration:**
|
||||
- Alle bestehenden User haben `auth_method = 'basic'`
|
||||
- `oauth_provider` und `oauth_provider_id` sind `NULL`
|
||||
- `password_hash` bleibt bestehen
|
||||
|
||||
### 10.2 API-Kompatibilität
|
||||
|
||||
**Bestehende Endpunkte:**
|
||||
- Funktionieren weiterhin mit Basic Auth
|
||||
- Neue Middleware prüft zuerst OAuth-Session, dann Basic Auth
|
||||
|
||||
**Middleware-Order:**
|
||||
```
|
||||
Request → oauthSessionMiddleware → basicAuthMiddleware → Handler
|
||||
↓ Invalid ↓ Invalid
|
||||
Try Basic Auth 401 Unauthorized
|
||||
```
|
||||
|
||||
### 10.3 User-Erstellung
|
||||
|
||||
**Admin-Erstellung:**
|
||||
- Admins können weiterhin lokale User erstellen
|
||||
- OAuth-User können manuell zu Admins gemacht werden
|
||||
|
||||
---
|
||||
|
||||
## 11. Konfiguration & Deployment
|
||||
|
||||
### 11.1 Environment Variables
|
||||
|
||||
```bash
|
||||
# OAuth Provider Credentials
|
||||
GOOGLE_CLIENT_ID=xxx
|
||||
GOOGLE_CLIENT_SECRET=xxx
|
||||
MICROSOFT_CLIENT_ID=xxx
|
||||
MICROSOFT_CLIENT_SECRET=xxx
|
||||
MICROSOFT_TENANT_ID=xxx
|
||||
GITHUB_CLIENT_ID=xxx
|
||||
GITHUB_CLIENT_SECRET=xxx
|
||||
|
||||
# OAuth Settings
|
||||
OAUTH_SESSION_SECRET=xxx # Für Session-Token Signing
|
||||
OAUTH_SESSION_LIFETIME=24h
|
||||
OAUTH_REDIRECT_BASE_URL=http://localhost:5173
|
||||
OAUTH_AUTO_CREATE_USERS=true
|
||||
OAUTH_AUTO_LINK_BY_EMAIL=false
|
||||
```
|
||||
|
||||
### 11.2 Provider-Registrierung
|
||||
|
||||
**Google:**
|
||||
1. Google Cloud Console → APIs & Services → Credentials
|
||||
2. OAuth 2.0 Client ID erstellen
|
||||
3. Authorized redirect URIs: `http://localhost:5173/api/oauth/google/callback`
|
||||
|
||||
**Microsoft:**
|
||||
1. Azure Portal → App Registrations
|
||||
2. Neue App registrieren
|
||||
3. Redirect URIs: `http://localhost:5173/api/oauth/microsoft/callback`
|
||||
|
||||
**GitHub:**
|
||||
1. GitHub Settings → Developer settings → OAuth Apps
|
||||
2. Neue OAuth App erstellen
|
||||
3. Authorization callback URL: `http://localhost:5173/api/oauth/github/callback`
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing & Rollout
|
||||
|
||||
### 12.1 Test-Plan
|
||||
|
||||
**Phase 1: Backend-Integration**
|
||||
- [ ] OAuth Provider Manager implementieren
|
||||
- [ ] OAuth Handler Endpunkte
|
||||
- [ ] Session Management
|
||||
- [ ] Datenbank-Migrationen
|
||||
|
||||
**Phase 2: Frontend-Integration**
|
||||
- [ ] OAuth Login Buttons
|
||||
- [ ] Callback Handler
|
||||
- [ ] Session Storage
|
||||
- [ ] AuthContext Erweiterung
|
||||
|
||||
**Phase 3: Testing**
|
||||
- [ ] Unit Tests für OAuth Flow
|
||||
- [ ] Integration Tests
|
||||
- [ ] Security Tests (CSRF, Token Validation)
|
||||
- [ ] User Acceptance Testing
|
||||
|
||||
**Phase 4: Rollout**
|
||||
- [ ] Staging Deployment
|
||||
- [ ] Production Deployment
|
||||
- [ ] Monitoring & Logging
|
||||
|
||||
### 12.2 Rollback-Plan
|
||||
|
||||
**Falls Probleme auftreten:**
|
||||
1. OAuth-Endpunkte deaktivieren (Feature Flag)
|
||||
2. Bestehende Basic Auth bleibt funktionsfähig
|
||||
3. Datenbank-Migrationen sind rückwärtskompatibel
|
||||
|
||||
---
|
||||
|
||||
## 13. Monitoring & Logging
|
||||
|
||||
### 13.1 Logging
|
||||
|
||||
**OAuth-Events:**
|
||||
- OAuth Flow Initiation
|
||||
- OAuth Callback (Erfolg/Fehler)
|
||||
- User-Erstellung via OAuth
|
||||
- Session-Erstellung/Löschung
|
||||
- Token-Refresh
|
||||
|
||||
**Structured Logging:**
|
||||
```go
|
||||
logOAuthEvent("oauth_login_initiated", map[string]interface{}{
|
||||
"provider": "google",
|
||||
"user_id": userID,
|
||||
"ip_address": ip,
|
||||
"trace_id": traceID,
|
||||
})
|
||||
```
|
||||
|
||||
### 13.2 Metrics
|
||||
|
||||
**Zu tracken:**
|
||||
- Anzahl OAuth-Logins pro Provider
|
||||
- Erfolgsrate OAuth Flows
|
||||
- Session-Dauer
|
||||
- Fehlerrate (Invalid Code, Token Expiry, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 14. Zukünftige Erweiterungen
|
||||
|
||||
### 14.1 Multi-Factor Authentication
|
||||
- OAuth als Second Factor für Basic Auth User
|
||||
|
||||
### 14.2 SSO (Single Sign-On)
|
||||
- SAML 2.0 Support
|
||||
- OpenID Connect
|
||||
|
||||
### 14.3 Social Login
|
||||
- Weitere Provider: Facebook, Twitter, LinkedIn
|
||||
|
||||
### 14.4 Account Management
|
||||
- UI für Verknüpfung mehrerer OAuth-Accounts
|
||||
- Entkopplung von OAuth-Accounts
|
||||
|
||||
---
|
||||
|
||||
## 15. Abhängigkeiten
|
||||
|
||||
### 15.1 Backend (Go)
|
||||
|
||||
```go
|
||||
// OAuth Libraries
|
||||
github.com/golang/oauth2
|
||||
github.com/coreos/go-oidc/v3/oidc // Für OpenID Connect
|
||||
|
||||
// JWT
|
||||
github.com/golang-jwt/jwt/v5
|
||||
|
||||
// Encryption
|
||||
golang.org/x/crypto
|
||||
```
|
||||
|
||||
### 15.2 Frontend
|
||||
|
||||
Keine zusätzlichen Dependencies nötig (native Fetch API für OAuth Flow)
|
||||
|
||||
---
|
||||
|
||||
## 16. Risiken & Mitigation
|
||||
|
||||
### 16.1 Risiken
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|
||||
|--------|-------------------|--------|------------|
|
||||
| OAuth Provider Downtime | Mittel | Hoch | Fallback auf Basic Auth |
|
||||
| Token-Leak | Niedrig | Sehr Hoch | HttpOnly Cookies, Token Rotation |
|
||||
| CSRF-Angriffe | Mittel | Hoch | State Parameter Validation |
|
||||
| Account Takeover | Niedrig | Sehr Hoch | Email-Verification, Rate Limiting |
|
||||
|
||||
### 16.2 Security Best Practices
|
||||
|
||||
- ✅ HTTPS nur (in Production)
|
||||
- ✅ State Parameter für CSRF Protection
|
||||
- ✅ Token-Verschlüsselung in DB
|
||||
- ✅ Session Timeout
|
||||
- ✅ Rate Limiting
|
||||
- ✅ Audit Logging
|
||||
|
||||
---
|
||||
|
||||
## 17. Zusammenfassung
|
||||
|
||||
### 17.1 Vorteile
|
||||
|
||||
- **Benutzerfreundlichkeit**: Ein-Klick-Login mit bekannten Accounts
|
||||
- **Sicherheit**: Keine Passwort-Verwaltung für OAuth-User
|
||||
- **Skalierbarkeit**: Externe Provider übernehmen Authentifizierung
|
||||
- **Flexibilität**: Hybrid-System unterstützt beide Methoden
|
||||
|
||||
### 17.2 Herausforderungen
|
||||
|
||||
- **Komplexität**: Zusätzliche Infrastruktur und Code
|
||||
- **Abhängigkeit**: Abhängig von externen Providern
|
||||
- **Migration**: Bestehende User müssen unterstützt werden
|
||||
- **Testing**: Mehr Test-Szenarien durch Multi-Provider
|
||||
|
||||
---
|
||||
|
||||
**Erstellt am**: 2025-01-XX
|
||||
**Version**: 1.0
|
||||
**Status**: Konzept - Noch nicht implementiert
|
||||
|
||||
74
docs/PASSWORD_SECURITY_ANALYSIS.md
Normal file
74
docs/PASSWORD_SECURITY_ANALYSIS.md
Normal 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?
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import { PermissionsProvider } from './contexts/PermissionsContext'
|
||||
import { ToastProvider, useToast } from './contexts/ToastContext'
|
||||
import { usePermissions } from './hooks/usePermissions'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import Footer from './components/Footer'
|
||||
import Home from './pages/Home'
|
||||
@@ -10,8 +13,10 @@ 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'
|
||||
import RenewalQueue from './pages/RenewalQueue'
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
@@ -34,6 +39,118 @@ const ProtectedRoute = ({ children }) => {
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Admin Only Route Component
|
||||
const AdminRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
const { isAdmin, 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 />
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
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">
|
||||
<p className="text-red-400 text-xl font-semibold mb-2">Zugriff verweigert</p>
|
||||
<p className="text-slate-300">Nur Administratoren haben Zugriff auf diese Seite.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
|
||||
const GroupRequiredRoute = ({ children, allowHomePage = false }) => {
|
||||
const { isAuthenticated, loading, user } = useAuth()
|
||||
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
|
||||
const location = useLocation()
|
||||
|
||||
// Warte, bis sowohl Auth als auch Permissions geladen sind
|
||||
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 />
|
||||
}
|
||||
|
||||
// Verwende isAdmin aus Permissions, oder Fallback auf user.isAdmin aus AuthContext
|
||||
// WICHTIG: Nur prüfen, wenn Permissions vollständig geladen sind (permissionsLoading === false)
|
||||
const effectiveIsAdmin = isAdmin || (user?.isAdmin === true)
|
||||
|
||||
// Admin oder User mit Gruppen haben Zugriff
|
||||
const hasGroups = effectiveIsAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
|
||||
|
||||
// Debug-Logging (kann später entfernt werden)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('GroupRequiredRoute Debug:', {
|
||||
isAdmin,
|
||||
userIsAdmin: user?.isAdmin,
|
||||
effectiveIsAdmin,
|
||||
hasFullAccess,
|
||||
accessibleSpacesLength: accessibleSpaces?.length || 0,
|
||||
hasGroups,
|
||||
pathname: location.pathname,
|
||||
allowHomePage,
|
||||
permissionsLoading,
|
||||
authLoading: loading
|
||||
})
|
||||
}
|
||||
|
||||
// Zeige Warnung, wenn keine Berechtigung vorhanden ist, aber leite nicht weiter
|
||||
// Dies verhindert, dass Benutzer beim Refresh zur Home-Seite weitergeleitet werden
|
||||
if (!hasGroups && !permissionsLoading) {
|
||||
// Erlaube Zugriff auf alle Seiten, aber zeige Warnung
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 p-4 rounded-lg border bg-yellow-500/20 border-yellow-500/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl flex-shrink-0 text-yellow-400">⚠️</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold mb-1 text-yellow-300">
|
||||
Keine Berechtigungsgruppe
|
||||
</p>
|
||||
<p className="text-sm text-yellow-200">
|
||||
Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
// Public Route Component (redirects to home if already logged in)
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
@@ -57,6 +174,153 @@ const PublicRoute = ({ children }) => {
|
||||
|
||||
const AppContent = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const { showToast } = useToast()
|
||||
|
||||
// Globale Validierungsmeldungen für alle required-Felder
|
||||
useEffect(() => {
|
||||
const setupCustomValidation = () => {
|
||||
// Funktion zum Setzen benutzerdefinierter Validierungsmeldungen
|
||||
const setCustomValidityMessages = () => {
|
||||
const requiredFields = document.querySelectorAll('input[required], select[required], textarea[required]')
|
||||
|
||||
requiredFields.forEach((field) => {
|
||||
// Überspringe Felder, die bereits behandelt wurden
|
||||
if (field.dataset.customValidation === 'true') {
|
||||
return
|
||||
}
|
||||
|
||||
// Markiere Feld als behandelt
|
||||
field.dataset.customValidation = 'true'
|
||||
|
||||
// Setze benutzerdefinierte Meldung basierend auf Feldtyp und Label
|
||||
const label = document.querySelector(`label[for="${field.id}"]`) ||
|
||||
field.closest('label') ||
|
||||
field.previousElementSibling
|
||||
|
||||
let customMessage = 'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
|
||||
|
||||
// Spezifische Meldungen basierend auf Feldtyp oder Label
|
||||
if (field.type === 'email') {
|
||||
customMessage = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'
|
||||
} else if (field.type === 'password') {
|
||||
customMessage = 'Bitte geben Sie ein Passwort ein.'
|
||||
} else if (field.tagName === 'SELECT') {
|
||||
customMessage = 'Bitte wählen Sie eine Option aus.'
|
||||
} else if (label) {
|
||||
const labelText = label.textContent || ''
|
||||
if (labelText.toLowerCase().includes('name') || labelText.toLowerCase().includes('benutzername')) {
|
||||
customMessage = 'Bitte geben Sie einen Namen ein.'
|
||||
} else if (labelText.toLowerCase().includes('fqdn') || labelText.toLowerCase().includes('domain')) {
|
||||
customMessage = 'Bitte geben Sie einen FQDN ein.'
|
||||
} else if (labelText.toLowerCase().includes('provider')) {
|
||||
customMessage = 'Bitte wählen Sie einen Provider aus.'
|
||||
} else if (labelText.toLowerCase().includes('beschreibung')) {
|
||||
customMessage = 'Bitte geben Sie eine Beschreibung ein.'
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere die benutzerdefinierte Meldung am Feld
|
||||
field.dataset.customMessage = customMessage
|
||||
|
||||
// Setze benutzerdefinierte Validierungsmeldung
|
||||
const handleInvalid = (e) => {
|
||||
e.preventDefault()
|
||||
field.setCustomValidity('') // Entferne Standard-Meldung
|
||||
|
||||
// Fallback: Zeige Toast auch bei einzelnen Feld-Validierungen
|
||||
// (wird normalerweise über handleFormSubmit gesteuert)
|
||||
// Nur wenn das Feld direkt validiert wird (z.B. bei onBlur)
|
||||
if (!field.form || !field.form.dataset.submitAttempted) {
|
||||
showToast(customMessage, 'error', 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// Entferne benutzerdefinierte Meldung bei Eingabe/Änderung
|
||||
const handleInput = () => {
|
||||
field.setCustomValidity('')
|
||||
}
|
||||
|
||||
field.addEventListener('invalid', handleInvalid)
|
||||
field.addEventListener('input', handleInput)
|
||||
field.addEventListener('change', handleInput)
|
||||
})
|
||||
}
|
||||
|
||||
// Initiale Einrichtung
|
||||
setCustomValidityMessages()
|
||||
|
||||
// Beobachte DOM-Änderungen für dynamisch hinzugefügte Formulare
|
||||
const observer = new MutationObserver(() => {
|
||||
// Kleine Verzögerung, damit React die DOM-Änderungen abgeschlossen hat
|
||||
setTimeout(() => {
|
||||
setCustomValidityMessages()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
|
||||
// Einrichtung bei Form-Submit
|
||||
const handleFormSubmit = (e) => {
|
||||
const form = e.target
|
||||
if (form.tagName === 'FORM') {
|
||||
// Markiere Formular als Submit-Versuch
|
||||
form.dataset.submitAttempted = 'true'
|
||||
|
||||
// Prüfe zuerst, ob das Formular gültig ist
|
||||
if (!form.checkValidity()) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// Sammle alle ungültigen Felder
|
||||
const invalidFields = Array.from(form.querySelectorAll('input[required], select[required], textarea[required]')).filter((field) => {
|
||||
// Prüfe explizit, ob das Feld ungültig ist
|
||||
if (field.type === 'checkbox' || field.type === 'radio') {
|
||||
return !field.checked && field.required
|
||||
}
|
||||
if (field.tagName === 'SELECT') {
|
||||
return !field.value && field.required
|
||||
}
|
||||
return !field.value.trim() && field.required
|
||||
})
|
||||
|
||||
if (invalidFields.length > 0) {
|
||||
// Fokussiere das erste ungültige Feld
|
||||
const firstInvalid = invalidFields[0]
|
||||
firstInvalid.focus()
|
||||
|
||||
// Sammle alle Validierungsmeldungen
|
||||
const messages = invalidFields.map((field) => {
|
||||
const customMessage = field.dataset.customMessage ||
|
||||
'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
|
||||
return customMessage
|
||||
})
|
||||
|
||||
// Zeige alle Toasts - die Queue im ToastContext sorgt für sequenzielle Anzeige
|
||||
messages.forEach((message) => {
|
||||
showToast(message, 'error', 5000)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Formular ist gültig, entferne Markierung
|
||||
delete form.dataset.submitAttempted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('submit', handleFormSubmit, true)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
document.removeEventListener('submit', handleFormSubmit, true)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = setupCustomValidation()
|
||||
return cleanup
|
||||
}, [showToast])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
@@ -66,14 +330,16 @@ const AppContent = () => {
|
||||
<div className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} />
|
||||
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} />
|
||||
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} />
|
||||
<Route path="/" element={<GroupRequiredRoute allowHomePage={true}><Home /></GroupRequiredRoute>} />
|
||||
<Route path="/queue" element={<GroupRequiredRoute><RenewalQueue /></GroupRequiredRoute>} />
|
||||
<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={<ProtectedRoute><Users /></ProtectedRoute>} />
|
||||
<Route path="/settings/permissions" element={<ProtectedRoute><Permissions /></ProtectedRoute>} />
|
||||
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} />
|
||||
<Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} />
|
||||
<Route path="/settings/permissions" element={<AdminRoute><Permissions /></AdminRoute>} />
|
||||
<Route path="/settings/providers" element={<AdminRoute><Providers /></AdminRoute>} />
|
||||
<Route path="/audit-logs" element={<GroupRequiredRoute><AuditLogs /></GroupRequiredRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
@@ -87,7 +353,11 @@ function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
<PermissionsProvider>
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</PermissionsProvider>
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { usePermissions } from '../hooks/usePermissions'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
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 - 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: '/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)
|
||||
|
||||
// Home mit Unterpunkten
|
||||
const homeMenu = {
|
||||
label: 'Home',
|
||||
icon: '🏠',
|
||||
path: '/',
|
||||
alwaysVisible: true,
|
||||
subItems: [
|
||||
{ path: '/queue', label: 'Queue', icon: '⏰', requiresGroups: true },
|
||||
].filter(item => !item.requiresGroups || hasGroups)
|
||||
}
|
||||
|
||||
// Settings mit Unterpunkten
|
||||
const settingsMenu = {
|
||||
@@ -23,6 +39,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: '🔒' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,7 +63,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
return expandedMenus[menuPath] || false
|
||||
}
|
||||
|
||||
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
|
||||
// Automatisch Menüs expandieren, wenn auf einer entsprechenden Seite
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
setExpandedMenus(prev => ({
|
||||
@@ -54,6 +71,12 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
'/settings': true
|
||||
}))
|
||||
}
|
||||
if (location.pathname === '/queue' || location.pathname === '/') {
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
'/': true
|
||||
}))
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
@@ -105,6 +128,82 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
</div>
|
||||
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
|
||||
<ul className="space-y-2 flex-1">
|
||||
{/* Home Menu mit Unterpunkten */}
|
||||
<li>
|
||||
<div className={`flex items-center rounded-lg transition-all duration-200 ${
|
||||
(isActive(homeMenu.path) || location.pathname === '/queue')
|
||||
? 'bg-slate-700 shadow-md'
|
||||
: ''
|
||||
}`}>
|
||||
<Link
|
||||
to={homeMenu.path}
|
||||
className={`flex-1 flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
|
||||
(isActive(homeMenu.path) || location.pathname === '/queue')
|
||||
? 'text-white font-semibold'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
title={!isOpen ? homeMenu.label : ''}
|
||||
>
|
||||
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
|
||||
{homeMenu.icon}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<span className="whitespace-nowrap overflow-hidden">
|
||||
{homeMenu.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{isOpen && homeMenu.subItems && homeMenu.subItems.length > 0 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleMenu(homeMenu.path)
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
(isActive(homeMenu.path) || location.pathname === '/queue')
|
||||
? 'text-white hover:bg-slate-600'
|
||||
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
|
||||
}`}
|
||||
title="Menü erweitern"
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-200 ${
|
||||
isMenuExpanded(homeMenu.path) ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && isMenuExpanded(homeMenu.path) && homeMenu.subItems && homeMenu.subItems.length > 0 && (
|
||||
<ul className="ml-4 mt-1 space-y-1">
|
||||
{homeMenu.subItems.map((subItem) => (
|
||||
<li key={subItem.path}>
|
||||
<Link
|
||||
to={subItem.path}
|
||||
className={`flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${
|
||||
isActive(subItem.path)
|
||||
? 'bg-slate-600 text-white font-semibold'
|
||||
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0 mr-2">
|
||||
{subItem.icon}
|
||||
</span>
|
||||
<span className="whitespace-nowrap overflow-hidden">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
@@ -128,7 +227,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Settings Menu mit Unterpunkten */}
|
||||
{/* Settings Menu mit Unterpunkten - nur für Admins */}
|
||||
{isAdmin && (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isOpen && toggleMenu(settingsMenu.path)}
|
||||
@@ -184,6 +284,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* Profil-Eintrag und Logout am unteren Ende */}
|
||||
<div className="mt-auto pt-2 border-t border-slate-700/50 space-y-2">
|
||||
|
||||
75
frontend/src/components/Toast.jsx
Normal file
75
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const Toast = ({ message, type = 'error', onClose, duration = 4000 }) => {
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [duration, onClose])
|
||||
|
||||
const typeStyles = {
|
||||
error: 'bg-red-500/90 border-red-400/50 text-white',
|
||||
warning: 'bg-amber-500/90 border-amber-400/50 text-white',
|
||||
info: 'bg-blue-500/90 border-blue-400/50 text-white',
|
||||
success: 'bg-emerald-500/90 border-emerald-400/50 text-white'
|
||||
}
|
||||
|
||||
const iconStyles = {
|
||||
error: 'text-red-200',
|
||||
warning: 'text-amber-200',
|
||||
info: 'text-blue-200',
|
||||
success: 'text-emerald-200'
|
||||
}
|
||||
|
||||
const icons = {
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-lg border-2 shadow-2xl backdrop-blur-sm min-w-[300px] max-w-[500px] animate-slide-in-right transition-all duration-300 ${typeStyles[type]}`}
|
||||
role="alert"
|
||||
>
|
||||
<div className={`flex-shrink-0 ${iconStyles[type]}`}>
|
||||
{icons[type]}
|
||||
</div>
|
||||
<div className="flex-1 text-sm font-medium">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
|
||||
221
frontend/src/contexts/PermissionsContext.jsx
Normal file
221
frontend/src/contexts/PermissionsContext.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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({
|
||||
isAdmin: false,
|
||||
hasFullAccess: false,
|
||||
accessibleSpaces: [],
|
||||
canCreateSpace: false,
|
||||
canDeleteSpace: false,
|
||||
canCreateFqdn: {},
|
||||
canDeleteFqdn: {},
|
||||
canUploadCSR: {},
|
||||
canSignCSR: {},
|
||||
})
|
||||
// Initial loading state: true wenn authentifiziert (weil Permissions noch nicht geladen sind)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const intervalRef = useRef(null)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
const fetchPermissions = useCallback(async (isInitial = false) => {
|
||||
if (!isAuthenticated) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (isInitial) {
|
||||
setLoading(true)
|
||||
}
|
||||
const response = await authFetch('/api/user/permissions')
|
||||
if (response.ok && isMountedRef.current) {
|
||||
try {
|
||||
const data = await response.json()
|
||||
// Debug-Logging (kann später entfernt werden)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Permissions loaded:', {
|
||||
isAdmin: data.isAdmin,
|
||||
hasFullAccess: data.hasFullAccess,
|
||||
accessibleSpacesLength: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces.length : 0
|
||||
})
|
||||
}
|
||||
// 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 || {},
|
||||
})
|
||||
// Setze loading nur auf false, wenn Permissions erfolgreich geladen wurden
|
||||
if (isInitial && isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.error('Error parsing permissions response:', parseErr)
|
||||
// Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
|
||||
// Setze loading auf false, damit die App nicht hängt
|
||||
if (isInitial && isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} 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')
|
||||
if (isInitial && isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
} else {
|
||||
// Bei anderen Fehlern (z.B. 500) loggen, aber loading auf false setzen
|
||||
const errorText = await response.text().catch(() => 'Unable to read error response')
|
||||
console.error('Error fetching permissions: HTTP', response.status, errorText)
|
||||
if (isInitial && isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching permissions:', err)
|
||||
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
|
||||
// Setze loading auf false, damit die App nicht hängt
|
||||
if (isInitial && isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, authFetch])
|
||||
|
||||
// Initiales Laden der Permissions
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
// Setze loading auf true, bevor Permissions geladen werden
|
||||
setLoading(true)
|
||||
fetchPermissions(true)
|
||||
} else {
|
||||
setPermissions({
|
||||
isAdmin: false,
|
||||
hasFullAccess: false,
|
||||
accessibleSpaces: [],
|
||||
canCreateSpace: false,
|
||||
canDeleteSpace: false,
|
||||
canCreateFqdn: {},
|
||||
canDeleteFqdn: {},
|
||||
canUploadCSR: {},
|
||||
canSignCSR: {},
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
}, [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
|
||||
const canDeleteFqdn = (spaceId) => permissions.canDeleteFqdn[spaceId] === true
|
||||
const canUploadCSR = (spaceId) => permissions.canUploadCSR[spaceId] === true
|
||||
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,
|
||||
isAdmin: permissions.isAdmin,
|
||||
hasFullAccess: permissions.hasFullAccess,
|
||||
accessibleSpaces: permissions.accessibleSpaces,
|
||||
canCreateSpace,
|
||||
canDeleteSpace,
|
||||
canCreateFqdn,
|
||||
canDeleteFqdn,
|
||||
canUploadCSR,
|
||||
canSignCSR,
|
||||
hasAccessToSpace,
|
||||
}
|
||||
|
||||
return <PermissionsContext.Provider value={value}>{children}</PermissionsContext.Provider>
|
||||
}
|
||||
|
||||
export const usePermissions = () => {
|
||||
const context = useContext(PermissionsContext)
|
||||
if (!context) {
|
||||
throw new Error('usePermissions muss innerhalb eines PermissionsProvider verwendet werden')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
98
frontend/src/contexts/ToastContext.jsx
Normal file
98
frontend/src/contexts/ToastContext.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
|
||||
import Toast from '../components/Toast'
|
||||
|
||||
const ToastContext = createContext()
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const queueRef = useRef([])
|
||||
const isProcessingRef = useRef(false)
|
||||
const timeoutRef = useRef(null)
|
||||
|
||||
// Sequenzielle Verarbeitung der Toast-Queue
|
||||
const processQueue = useCallback(() => {
|
||||
// Wenn bereits verarbeitet wird oder Queue leer, nichts tun
|
||||
if (isProcessingRef.current || queueRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingRef.current = true
|
||||
const nextToast = queueRef.current.shift()
|
||||
|
||||
if (nextToast) {
|
||||
// Füge Toast zum State hinzu
|
||||
setToasts((prev) => [...prev, nextToast])
|
||||
|
||||
// Warte 200ms bevor der nächste Toast verarbeitet wird
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
isProcessingRef.current = false
|
||||
// Verarbeite nächsten Toast in der Queue
|
||||
processQueue()
|
||||
}, 200)
|
||||
} else {
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Starte Verarbeitung wenn Queue nicht leer ist
|
||||
useEffect(() => {
|
||||
if (queueRef.current.length > 0 && !isProcessingRef.current) {
|
||||
processQueue()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [processQueue])
|
||||
|
||||
const showToast = useCallback((message, type = 'error', duration = 4000) => {
|
||||
const id = Date.now() + Math.random()
|
||||
const newToast = { id, message, type, duration }
|
||||
|
||||
// Füge zur Queue hinzu
|
||||
queueRef.current.push(newToast)
|
||||
|
||||
// Starte Verarbeitung wenn nicht bereits aktiv
|
||||
if (!isProcessingRef.current) {
|
||||
processQueue()
|
||||
}
|
||||
|
||||
return id
|
||||
}, [processQueue])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, removeToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 pointer-events-none flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
1
frontend/src/hooks/usePermissions.js
Normal file
1
frontend/src/hooks/usePermissions.js
Normal file
@@ -0,0 +1 @@
|
||||
export { usePermissions } from '../contexts/PermissionsContext'
|
||||
@@ -55,3 +55,26 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast Animation */
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Toast Container - Stapelung mehrerer Toasts */
|
||||
.toast-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// Custom Dropdown Component
|
||||
const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || { label: placeholder }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 active:bg-slate-700 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm flex items-center justify-between"
|
||||
>
|
||||
<span className={!value ? 'text-slate-400' : 'text-white'}>{selectedOption.label}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-slate-800/95 backdrop-blur-sm border border-slate-600/50 rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||
value === option.value
|
||||
? 'bg-blue-600/20 text-blue-300 border-l-2 border-blue-500 font-medium'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [logs, setLogs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(null)
|
||||
const [filters, setFilters] = useState({
|
||||
action: '',
|
||||
resourceType: '',
|
||||
@@ -23,6 +90,7 @@ const AuditLogs = () => {
|
||||
try {
|
||||
if (!silent) {
|
||||
setLoading(true)
|
||||
setIsRefreshing(true)
|
||||
}
|
||||
setError('')
|
||||
|
||||
@@ -41,9 +109,8 @@ const AuditLogs = () => {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Audit-Logs Response:', data)
|
||||
console.log('Anzahl Logs:', data.logs?.length || 0)
|
||||
setLogs(data.logs || [])
|
||||
setLastUpdate(new Date())
|
||||
setPagination({
|
||||
...pagination,
|
||||
total: data.total || 0,
|
||||
@@ -57,6 +124,7 @@ const AuditLogs = () => {
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +140,7 @@ const AuditLogs = () => {
|
||||
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset, authFetch])
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
@@ -163,6 +231,7 @@ const AuditLogs = () => {
|
||||
csr: 'CSR',
|
||||
provider: 'Provider',
|
||||
certificate: 'Zertifikat',
|
||||
permission_group: 'Berechtigungsgruppen',
|
||||
}
|
||||
|
||||
const toggleLogExpansion = (logId) => {
|
||||
@@ -187,6 +256,18 @@ const AuditLogs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastUpdate = () => {
|
||||
if (!lastUpdate) return ''
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - lastUpdate) / 1000)
|
||||
if (diff < 5) return 'Gerade eben'
|
||||
if (diff < 60) return `Vor ${diff} Sekunden`
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.action || filters.resourceType || filters.userId
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -195,54 +276,80 @@ const AuditLogs = () => {
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
||||
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Live-Aktualisierung aktiv</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">Aktualisiere...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/50 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-300 font-medium">Live</span>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<div className="text-sm text-slate-400">
|
||||
{formatLastUpdate()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Aktion
|
||||
</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters({ action: '', resourceType: '', userId: '' })
|
||||
setPagination({ ...pagination, offset: 0 })
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors duration-200 text-sm"
|
||||
>
|
||||
<option value="">Alle Aktionen</option>
|
||||
<option value="CREATE">Erstellt</option>
|
||||
<option value="UPDATE">Aktualisiert</option>
|
||||
<option value="DELETE">Gelöscht</option>
|
||||
<option value="UPLOAD">Hochgeladen</option>
|
||||
<option value="SIGN">Signiert</option>
|
||||
<option value="ENABLE">Aktiviert</option>
|
||||
<option value="DISABLE">Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Ressourcentyp
|
||||
</label>
|
||||
<select
|
||||
value={filters.resourceType}
|
||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="space">Space</option>
|
||||
<option value="fqdn">FQDN</option>
|
||||
<option value="csr">CSR</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<CustomDropdown
|
||||
label="Aktion"
|
||||
value={filters.action}
|
||||
onChange={(value) => handleFilterChange('action', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Alle Aktionen' },
|
||||
{ value: 'CREATE', label: 'Erstellt' },
|
||||
{ value: 'UPDATE', label: 'Aktualisiert' },
|
||||
{ value: 'DELETE', label: 'Gelöscht' },
|
||||
{ value: 'UPLOAD', label: 'Hochgeladen' },
|
||||
{ value: 'SIGN', label: 'Signiert' },
|
||||
{ value: 'ENABLE', label: 'Aktiviert' },
|
||||
{ value: 'DISABLE', label: 'Deaktiviert' }
|
||||
]}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Ressourcentyp"
|
||||
value={filters.resourceType}
|
||||
onChange={(value) => handleFilterChange('resourceType', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'user', label: 'Benutzer' },
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'fqdn', label: 'FQDN' },
|
||||
{ value: 'csr', label: 'CSR' },
|
||||
{ value: 'provider', label: 'Provider' },
|
||||
{ value: 'certificate', label: 'Zertifikat' },
|
||||
{ value: 'permission_group', label: 'Berechtigungsgruppen' }
|
||||
]}
|
||||
/>
|
||||
<div className="relative">
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
Benutzer-ID
|
||||
</label>
|
||||
<input
|
||||
@@ -250,7 +357,7 @@ const AuditLogs = () => {
|
||||
value={filters.userId}
|
||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||
placeholder="Benutzer-ID filtern"
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm placeholder-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 '../hooks/usePermissions'
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { usePermissions } from '../contexts/PermissionsContext'
|
||||
|
||||
const Permissions = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const { refreshPermissions } = usePermissions()
|
||||
const [groups, setGroups] = useState([])
|
||||
const [spaces, setSpaces] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -112,6 +114,8 @@ const Permissions = () => {
|
||||
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
|
||||
setShowForm(false)
|
||||
setEditingGroup(null)
|
||||
// Aktualisiere Berechtigungen nach Änderung an Berechtigungsgruppen
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Speichern der Berechtigungsgruppe')
|
||||
@@ -156,6 +160,8 @@ const Permissions = () => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
setConfirmChecked(false)
|
||||
// Aktualisiere Berechtigungen nach Löschen einer Berechtigungsgruppe
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Fehler beim Löschen' }))
|
||||
alert(errorData.error || 'Fehler beim Löschen der Berechtigungsgruppe')
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { usePermissions } from '../hooks/usePermissions'
|
||||
|
||||
const Profile = () => {
|
||||
const { authFetch, user } = useAuth()
|
||||
const { isAdmin } = usePermissions()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuccessAnimation, setShowSuccessAnimation] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -286,8 +288,10 @@ const Profile = () => {
|
||||
|
||||
try {
|
||||
const body = {
|
||||
...(formData.username && { username: formData.username }),
|
||||
...(formData.email && { email: formData.email }),
|
||||
// Nur der spezielle Admin-User mit UID 'admin': Username und Email nicht ändern
|
||||
// Andere Admin-User können ihre Daten ändern
|
||||
...(user?.id !== 'admin' && formData.username && { username: formData.username }),
|
||||
...(user?.id !== 'admin' && formData.email && { email: formData.email }),
|
||||
...(formData.password && {
|
||||
password: formData.password,
|
||||
oldPassword: formData.oldPassword
|
||||
@@ -414,9 +418,15 @@ const Profile = () => {
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={user?.id === 'admin'}
|
||||
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 ${
|
||||
user?.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
placeholder="Geben Sie Ihren Benutzernamen ein"
|
||||
/>
|
||||
{user?.id === 'admin' && (
|
||||
<p className="mt-1 text-xs text-slate-400">Der Benutzername des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -430,9 +440,15 @@ const Profile = () => {
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={user?.id === 'admin'}
|
||||
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 ${
|
||||
user?.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
placeholder="Geben Sie Ihre E-Mail-Adresse ein"
|
||||
/>
|
||||
{user?.id === 'admin' && (
|
||||
<p className="mt-1 text-xs text-slate-400">Die E-Mail-Adresse des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50">
|
||||
|
||||
342
frontend/src/pages/Providers.jsx
Normal file
342
frontend/src/pages/Providers.jsx
Normal 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
|
||||
|
||||
379
frontend/src/pages/RenewalQueue.jsx
Normal file
379
frontend/src/pages/RenewalQueue.jsx
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// Custom Dropdown Component
|
||||
const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || { label: placeholder }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 active:bg-slate-700 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm flex items-center justify-between"
|
||||
>
|
||||
<span className={value === 'all' ? 'text-slate-400' : 'text-white'}>{selectedOption.label}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-slate-800/95 backdrop-blur-sm border border-slate-600/50 rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||
value === option.value
|
||||
? 'bg-blue-600/20 text-blue-300 border-l-2 border-blue-500 font-medium'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenewalQueue = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [queue, setQueue] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [spaceFilter, setSpaceFilter] = useState('all')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(null)
|
||||
|
||||
const fetchQueue = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) {
|
||||
setIsRefreshing(true)
|
||||
}
|
||||
const response = await authFetch('/api/renewal-queue')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setQueue(data.queue || [])
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Error fetching renewal queue:', err)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue()
|
||||
// Live-Refresh alle 5 Sekunden für Echtzeit-Ansicht
|
||||
const interval = setInterval(() => fetchQueue(true), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [authFetch])
|
||||
|
||||
// Extrahiere eindeutige Spaces für Filter
|
||||
const uniqueSpaces = useMemo(() => {
|
||||
const spaces = new Set()
|
||||
queue.forEach(item => {
|
||||
if (item.spaceName) {
|
||||
spaces.add(item.spaceName)
|
||||
}
|
||||
})
|
||||
return Array.from(spaces).sort()
|
||||
}, [queue])
|
||||
|
||||
// Filtere und sortiere Queue-Einträge
|
||||
const filteredAndSortedQueue = useMemo(() => {
|
||||
let filtered = queue
|
||||
|
||||
// Filter nach Status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === statusFilter)
|
||||
}
|
||||
|
||||
// Filter nach Space
|
||||
if (spaceFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.spaceName === spaceFilter)
|
||||
}
|
||||
|
||||
// Sortiere nach scheduled_at (nächste oben)
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.scheduledAt)
|
||||
const dateB = new Date(b.scheduledAt)
|
||||
return dateA - dateB
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [queue, statusFilter, spaceFilter])
|
||||
|
||||
// Teile in "Ausstehend" und "Erledigt"
|
||||
const pendingQueue = useMemo(() => {
|
||||
return filteredAndSortedQueue.filter(item =>
|
||||
item.status === 'pending' || item.status === 'processing'
|
||||
)
|
||||
}, [filteredAndSortedQueue])
|
||||
|
||||
const completedQueue = useMemo(() => {
|
||||
return filteredAndSortedQueue.filter(item =>
|
||||
item.status === 'completed' || item.status === 'failed'
|
||||
)
|
||||
}, [filteredAndSortedQueue])
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||
case 'processing':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
case 'success':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
case 'completed':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
case 'failed':
|
||||
return 'bg-red-500/20 text-red-400 border-red-500/50'
|
||||
default:
|
||||
return 'bg-slate-500/20 text-slate-400 border-slate-500/50'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
// Prüfe ob es bereits im DB-Format ist (YYYY-MM-DD HH:MM:SS)
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/)
|
||||
if (match) {
|
||||
// Parse als UTC und formatiere
|
||||
const [, year, month, day, hour, minute] = match
|
||||
const date = new Date(Date.UTC(
|
||||
parseInt(year, 10),
|
||||
parseInt(month, 10) - 1,
|
||||
parseInt(day, 10),
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10)
|
||||
))
|
||||
return date.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC'
|
||||
})
|
||||
}
|
||||
// Fallback: Versuche als ISO-String zu parsen
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateStr // Falls Parsing fehlschlägt, gib Original zurück
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Formatieren des Datums:', err, dateStr)
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const renderQueueTable = (items, title) => {
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">{title}</h2>
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">FQDN</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Space</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Geplant für</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Verarbeitet</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-700/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white font-medium">{item.fqdn || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{item.spaceName || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.scheduledAt)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(item.status)}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.processedAt)}</td>
|
||||
<td className="px-6 py-4 text-sm text-red-400">{item.errorMessage || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const formatLastUpdate = () => {
|
||||
if (!lastUpdate) return ''
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - lastUpdate) / 1000)
|
||||
if (diff < 5) return 'Gerade eben'
|
||||
if (diff < 60) return `Vor ${diff} Sekunden`
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="max-w-10xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Renewal Queue</h1>
|
||||
<p className="text-lg text-slate-200">
|
||||
Übersicht über geplante und laufende Zertifikatserneuerungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">Aktualisiere...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/50 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-300 font-medium">Live</span>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<div className="text-sm text-slate-400">
|
||||
{formatLastUpdate()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||
<p className="text-slate-300 mt-4">Lade Queue...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Filter */}
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||
{(statusFilter !== 'all' || spaceFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('all')
|
||||
setSpaceFilter('all')
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors duration-200 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'Alle Status' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' }
|
||||
]}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Space"
|
||||
value={spaceFilter}
|
||||
onChange={setSpaceFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'Alle Spaces' },
|
||||
...uniqueSpaces.map(space => ({ value: space, label: space }))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausstehende Tasks */}
|
||||
{renderQueueTable(pendingQueue, `Ausstehend (${pendingQueue.length})`)}
|
||||
|
||||
{/* Erledigte Tasks */}
|
||||
{renderQueueTable(completedQueue, `Erledigt (${completedQueue.length})`)}
|
||||
|
||||
{/* Keine Einträge */}
|
||||
{filteredAndSortedQueue.length === 0 && (
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-12 text-center">
|
||||
<p className="text-slate-400 text-lg">
|
||||
{queue.length === 0
|
||||
? 'Keine Einträge in der Renewal Queue'
|
||||
: 'Keine Einträge entsprechen den gewählten Filtern'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RenewalQueue
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { usePermissions } from '../contexts/PermissionsContext'
|
||||
|
||||
const Spaces = () => {
|
||||
const navigate = useNavigate()
|
||||
const { authFetch } = useAuth()
|
||||
const { canCreateSpace, canDeleteSpace, refreshPermissions } = usePermissions()
|
||||
const [spaces, setSpaces] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -72,6 +74,8 @@ const Spaces = () => {
|
||||
setSpaces([...spaces, newSpace])
|
||||
setFormData({ name: '', description: '' })
|
||||
setShowForm(false)
|
||||
// Aktualisiere Berechtigungen nach dem Erstellen eines Spaces
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Erstellen des Space')
|
||||
@@ -140,6 +144,8 @@ const Spaces = () => {
|
||||
setConfirmChecked(false)
|
||||
setDeleteFqdnsChecked(false)
|
||||
setFqdnCount(0)
|
||||
// Aktualisiere Berechtigungen nach dem Löschen eines Spaces
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = 'Fehler beim Löschen des Space'
|
||||
@@ -188,7 +194,13 @@ const Spaces = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
disabled={!canCreateSpace() && !showForm}
|
||||
className={`px-6 py-3 font-semibold rounded-lg shadow-lg transition-all duration-200 ${
|
||||
canCreateSpace() || showForm
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white hover:shadow-xl'
|
||||
: 'bg-slate-600 text-slate-400 cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
title={!canCreateSpace() && !showForm ? 'Keine Berechtigung zum Erstellen von Spaces' : ''}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : '+ Neuer Space'}
|
||||
</button>
|
||||
@@ -196,66 +208,138 @@ const Spaces = () => {
|
||||
|
||||
{/* Create Space Form */}
|
||||
{showForm && (
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||
Neuen Space erstellen
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Geben Sie einen Namen ein"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="4"
|
||||
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 resize-none"
|
||||
placeholder="Geben Sie eine Beschreibung ein (optional)"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
<div className="bg-gradient-to-br from-slate-800/90 to-slate-900/90 backdrop-blur-sm rounded-xl shadow-2xl border border-emerald-500/30 p-8 mb-6 relative overflow-hidden">
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl -mr-32 -mt-32"></div>
|
||||
<div className="absolute bottom-0 left-0 w-48 h-48 bg-teal-500/10 rounded-full blur-3xl -ml-24 -mb-24"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Header with Icon */}
|
||||
<div className="flex items-center mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center mr-4 shadow-lg shadow-emerald-500/25">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Neuen Space erstellen
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Erstellen Sie einen neuen Arbeitsbereich für Ihre Zertifikate
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200"
|
||||
>
|
||||
{loading ? 'Wird erstellt...' : 'Space erstellen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false)
|
||||
setFormData({ name: '', description: '' })
|
||||
setError('')
|
||||
}}
|
||||
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="mb-6 p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start">
|
||||
<svg className="w-5 h-5 text-emerald-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-emerald-300">
|
||||
Spaces sind Arbeitsbereiche, in denen Sie FQDNs und Zertifikate organisieren können. Jeder Space kann mehrere FQDNs enthalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name Input */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="flex items-center text-sm font-semibold text-slate-200">
|
||||
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
Name *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 pl-11 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500"
|
||||
placeholder="z.B. Produktion, Entwicklung, Test"
|
||||
required
|
||||
/>
|
||||
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description Textarea */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="description" className="flex items-center text-sm font-semibold text-slate-200">
|
||||
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
Beschreibung
|
||||
<span className="ml-2 text-xs font-normal text-slate-400">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows="4"
|
||||
className="w-full px-4 py-3 pl-11 pt-3 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500 resize-none"
|
||||
placeholder="Geben Sie eine Beschreibung ein (z.B. Verwendungszweck, Standort, Team, etc.)"
|
||||
/>
|
||||
<svg className="absolute left-3 top-3 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/20 border-2 border-red-500/50 rounded-lg flex items-start">
|
||||
<svg className="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-red-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200 shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 disabled:shadow-none flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Wird erstellt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Space erstellen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false)
|
||||
setFormData({ name: '', description: '' })
|
||||
setError('')
|
||||
}}
|
||||
className="px-6 py-3 bg-slate-700/70 hover:bg-slate-700 border-2 border-slate-600 hover:border-slate-500 text-white font-semibold rounded-lg transition-all duration-200"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -340,11 +424,18 @@ const Spaces = () => {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(space)
|
||||
if (canDeleteSpace(space.id)) {
|
||||
handleDelete(space)
|
||||
}
|
||||
}}
|
||||
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||
title="Space löschen"
|
||||
aria-label="Space löschen"
|
||||
disabled={!canDeleteSpace(space.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
canDeleteSpace(space.id)
|
||||
? 'text-red-400 hover:text-red-300 hover:bg-red-500/20'
|
||||
: 'text-slate-500 cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
title={canDeleteSpace(space.id) ? 'Space löschen' : 'Keine Berechtigung zum Löschen von Spaces'}
|
||||
aria-label={canDeleteSpace(space.id) ? 'Space löschen' : 'Keine Berechtigung'}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { usePermissions } from '../contexts/PermissionsContext'
|
||||
|
||||
const Users = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const { refreshPermissions } = usePermissions()
|
||||
const [users, setUsers] = useState([])
|
||||
const [groups, setGroups] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -12,14 +14,21 @@ const Users = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [userToDelete, setUserToDelete] = useState(null)
|
||||
const [confirmChecked, setConfirmChecked] = useState(false)
|
||||
const [showToggleModal, setShowToggleModal] = useState(false)
|
||||
const [userToToggle, setUserToToggle] = useState(null)
|
||||
const [confirmToggleChecked, setConfirmToggleChecked] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isAdmin: false,
|
||||
enabled: true,
|
||||
groupIds: []
|
||||
})
|
||||
const [showAdminWarning, setShowAdminWarning] = useState(false)
|
||||
const [pendingAdminChange, setPendingAdminChange] = useState(null) // Speichert den vorherigen isAdmin Wert
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
@@ -81,18 +90,24 @@ const Users = () => {
|
||||
|
||||
const body = editingUser
|
||||
? {
|
||||
...(formData.username && { username: formData.username }),
|
||||
...(formData.email && { email: formData.email }),
|
||||
// Username/Email nur setzen wenn nicht der spezielle Admin-User mit UID 'admin'
|
||||
...(formData.username && editingUser.id !== 'admin' && { username: formData.username }),
|
||||
...(formData.email && editingUser.id !== 'admin' && { email: formData.email }),
|
||||
...(formData.password && {
|
||||
password: formData.password,
|
||||
oldPassword: formData.oldPassword
|
||||
}),
|
||||
// isAdmin nur setzen wenn nicht UID 'admin' (UID 'admin' ist immer Admin)
|
||||
...(formData.isAdmin !== undefined && editingUser.id !== 'admin' && { isAdmin: formData.isAdmin }),
|
||||
// enabled wird nicht über das Bearbeitungsformular geändert, nur über den Button in der Liste
|
||||
...(formData.groupIds !== undefined && { groupIds: formData.groupIds })
|
||||
}
|
||||
: {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
isAdmin: formData.isAdmin || false,
|
||||
enabled: true, // Neue User sind immer aktiviert, enabled kann nur für UID 'admin' geändert werden
|
||||
groupIds: formData.groupIds || []
|
||||
}
|
||||
|
||||
@@ -106,9 +121,13 @@ const Users = () => {
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUsers()
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] })
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
|
||||
setShowForm(false)
|
||||
setEditingUser(null)
|
||||
setShowAdminWarning(false)
|
||||
setPendingAdminChange(null)
|
||||
// Aktualisiere Berechtigungen nach Änderung an Benutzern (Gruppen-Zuweisungen könnten sich geändert haben)
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Speichern des Benutzers')
|
||||
@@ -129,6 +148,8 @@ const Users = () => {
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isAdmin: user.isAdmin || false,
|
||||
enabled: user.enabled !== undefined ? user.enabled : true, // Wird nicht im Formular angezeigt, nur für internen Zustand
|
||||
groupIds: user.groupIds || []
|
||||
})
|
||||
setShowForm(true)
|
||||
@@ -140,6 +161,67 @@ const Users = () => {
|
||||
setConfirmChecked(false)
|
||||
}
|
||||
|
||||
const handleToggleEnabled = (user) => {
|
||||
if (user.id !== 'admin') {
|
||||
return
|
||||
}
|
||||
setUserToToggle(user)
|
||||
setShowToggleModal(true)
|
||||
setConfirmToggleChecked(false)
|
||||
}
|
||||
|
||||
const confirmToggle = async () => {
|
||||
if (!confirmToggleChecked || !userToToggle) {
|
||||
return
|
||||
}
|
||||
|
||||
const newEnabled = !userToToggle.enabled
|
||||
const action = newEnabled ? 'aktivieren' : 'deaktivieren'
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await authFetch(`/api/users/${userToToggle.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: newEnabled
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUsers()
|
||||
setShowToggleModal(false)
|
||||
setUserToToggle(null)
|
||||
setConfirmToggleChecked(false)
|
||||
// Aktualisiere Berechtigungen nach Änderung
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: `Fehler beim ${action}` }))
|
||||
const errorMessage = errorData.error || `Fehler beim ${action} des Admin-Users`
|
||||
setError(errorMessage)
|
||||
// Schließe Modal bei Fehler
|
||||
if (response.status === 403) {
|
||||
setShowToggleModal(false)
|
||||
setUserToToggle(null)
|
||||
setConfirmToggleChecked(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error toggling enabled for admin user:`, err)
|
||||
setError(`Fehler beim ${action} des Admin-Users`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelToggle = () => {
|
||||
setShowToggleModal(false)
|
||||
setUserToToggle(null)
|
||||
setConfirmToggleChecked(false)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!confirmChecked || !userToDelete) {
|
||||
return
|
||||
@@ -155,13 +237,23 @@ const Users = () => {
|
||||
setShowDeleteModal(false)
|
||||
setUserToDelete(null)
|
||||
setConfirmChecked(false)
|
||||
// Aktualisiere Berechtigungen nach Löschen eines Benutzers
|
||||
refreshPermissions()
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Fehler beim Löschen' }))
|
||||
alert(errorData.error || 'Fehler beim Löschen des Benutzers')
|
||||
const errorMessage = errorData.error || 'Fehler beim Löschen des Benutzers'
|
||||
// Zeige Fehlermeldung
|
||||
setError(errorMessage)
|
||||
// Wenn Admin-Löschung verhindert wurde, schließe Modal
|
||||
if (response.status === 403) {
|
||||
setShowDeleteModal(false)
|
||||
setUserToDelete(null)
|
||||
setConfirmChecked(false)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting user:', err)
|
||||
alert('Fehler beim Löschen des Benutzers')
|
||||
setError('Fehler beim Löschen des Benutzers')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +281,31 @@ const Users = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdminToggle = (e) => {
|
||||
const isAdmin = e.target.checked
|
||||
const previousIsAdmin = formData.isAdmin
|
||||
|
||||
// Wenn Admin aktiviert wird, zeige Warnung und setze Wert NICHT sofort
|
||||
if (isAdmin && !showAdminWarning) {
|
||||
// Speichere den vorherigen Wert
|
||||
setPendingAdminChange(previousIsAdmin)
|
||||
setShowAdminWarning(true)
|
||||
// Wert NICHT setzen - wird erst gesetzt wenn Benutzer bestätigt
|
||||
// Checkbox bleibt visuell unverändert durch controlled component
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn Admin deaktiviert wird (Checkbox wird abgewählt), setze sofort
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
isAdmin,
|
||||
// Wenn Admin deaktiviert wird, Gruppen können wieder gesetzt werden
|
||||
groupIds: isAdmin ? [] : prev.groupIds,
|
||||
enabled: isAdmin ? true : (prev.enabled !== undefined ? prev.enabled : true)
|
||||
}))
|
||||
setPendingAdminChange(null)
|
||||
}
|
||||
|
||||
const getPermissionLabel = (permission) => {
|
||||
switch (permission) {
|
||||
case 'READ':
|
||||
@@ -216,7 +333,9 @@ const Users = () => {
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingUser(null)
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] })
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
|
||||
setShowAdminWarning(false)
|
||||
setPendingAdminChange(null)
|
||||
}}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
@@ -242,9 +361,15 @@ const Users = () => {
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={editingUser && editingUser.id === 'admin'}
|
||||
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 ${
|
||||
editingUser && editingUser.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
placeholder="Geben Sie einen Benutzernamen ein"
|
||||
/>
|
||||
{editingUser && editingUser.id === 'admin' && (
|
||||
<p className="mt-1 text-xs text-slate-400">Der Benutzername des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
@@ -257,9 +382,15 @@ const Users = () => {
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={editingUser && editingUser.id === 'admin'}
|
||||
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 ${
|
||||
editingUser && editingUser.id === 'admin' ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
placeholder="Geben Sie eine E-Mail-Adresse ein"
|
||||
/>
|
||||
{editingUser && editingUser.id === 'admin' && (
|
||||
<p className="mt-1 text-xs text-slate-400">Die E-Mail-Adresse des Admin-Users mit UID 'admin' kann nicht geändert werden</p>
|
||||
)}
|
||||
</div>
|
||||
{editingUser && (
|
||||
<div>
|
||||
@@ -344,21 +475,56 @@ const Users = () => {
|
||||
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Checkbox - nicht für UID 'admin' */}
|
||||
{(!editingUser || editingUser.id !== 'admin') && (
|
||||
<div className="bg-slate-700/30 border border-slate-600/50 rounded-lg p-4">
|
||||
<label className="flex items-start cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isAdmin || false}
|
||||
onChange={handleAdminToggle}
|
||||
disabled={editingUser && editingUser.id === 'admin'} // Admin user kann seinen Status nicht ändern
|
||||
className="mt-1 w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-800 cursor-pointer"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-200 font-semibold">Administrator</span>
|
||||
<span className="px-2 py-0.5 bg-red-600/20 text-red-300 rounded text-xs font-medium">
|
||||
VOLLZUGRIFF
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Ein Administrator hat vollständigen Zugriff auf alle Funktionen und kann alle Einstellungen verwalten.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Berechtigungsgruppen - ausgegraut wenn Admin oder UID 'admin' */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Berechtigungsgruppen
|
||||
{(formData.isAdmin || (editingUser && editingUser.id === 'admin')) && <span className="text-xs text-slate-400 ml-2">(nicht verfügbar für Administratoren)</span>}
|
||||
</label>
|
||||
<div className="bg-slate-700/50 border border-slate-600 rounded-lg p-4 max-h-60 overflow-y-auto">
|
||||
<div className={`bg-slate-700/50 border border-slate-600 rounded-lg p-4 max-h-60 overflow-y-auto ${
|
||||
formData.isAdmin || (editingUser && editingUser.id === 'admin') ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-slate-400 text-sm">Keine Berechtigungsgruppen vorhanden</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{groups.map(group => (
|
||||
<label key={group.id} className="flex items-start cursor-pointer hover:bg-slate-600/50 p-2 rounded">
|
||||
<label key={group.id} className={`flex items-start p-2 rounded ${
|
||||
formData.isAdmin || (editingUser && editingUser.id === 'admin') ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-slate-600/50'
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.groupIds?.includes(group.id) || false}
|
||||
onChange={() => handleGroupToggle(group.id)}
|
||||
disabled={formData.isAdmin || (editingUser && editingUser.id === 'admin')}
|
||||
className="w-4 h-4 text-blue-600 bg-slate-600 border-slate-500 rounded focus:ring-blue-500 mt-1"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
@@ -396,7 +562,9 @@ const Users = () => {
|
||||
onClick={() => {
|
||||
setShowForm(false)
|
||||
setEditingUser(null)
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] })
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
|
||||
setShowAdminWarning(false)
|
||||
setPendingAdminChange(null)
|
||||
setError('')
|
||||
}}
|
||||
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
@@ -437,11 +605,23 @@ const Users = () => {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{user.username}
|
||||
</h3>
|
||||
<p className="text-slate-300 mb-2">{user.email}</p>
|
||||
{user.groupIds && user.groupIds.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{user.username}
|
||||
</h3>
|
||||
{user.isAdmin && (
|
||||
<span className="px-2 py-0.5 bg-red-600/20 text-red-300 rounded text-xs font-medium border border-red-500/30">
|
||||
ADMIN
|
||||
</span>
|
||||
)}
|
||||
{user.id === 'admin' && user.enabled === false && (
|
||||
<span className="px-2 py-0.5 bg-red-600/20 text-red-300 rounded text-xs font-medium border border-red-500/30">
|
||||
DEAKTIVIERT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-300 mb-2">{user.email}</p>
|
||||
{!user.isAdmin && user.groupIds && user.groupIds.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-sm text-slate-300 mb-1">Berechtigungsgruppen:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -472,12 +652,26 @@ const Users = () => {
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
{user.id === 'admin' ? (
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
className={`px-4 py-2 text-white text-sm rounded-lg transition-colors ${
|
||||
user.enabled
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
title={user.enabled ? "Admin-User deaktivieren" : "Admin-User aktivieren"}
|
||||
>
|
||||
{user.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,6 +680,84 @@ const Users = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Admin Warning Modal */}
|
||||
{showAdminWarning && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-800 rounded-xl shadow-2xl border border-red-600/50 max-w-md w-full p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-red-500/20 rounded-full flex items-center justify-center mr-4">
|
||||
<svg
|
||||
className="w-6 h-6 text-red-400"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
Administrator-Berechtigung
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-slate-300 mb-3">
|
||||
Sie sind dabei, diesem Benutzer <span className="font-semibold text-red-400">Administrator-Rechte</span> zu gewähren.
|
||||
</p>
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 mb-4">
|
||||
<p className="text-sm font-semibold text-red-300 mb-2">⚠️ Mögliche Gefahren:</p>
|
||||
<ul className="text-xs text-slate-300 space-y-1 list-disc list-inside">
|
||||
<li>Vollständiger Zugriff auf alle Funktionen und Einstellungen</li>
|
||||
<li>Möglichkeit, andere Benutzer zu erstellen, zu bearbeiten oder zu löschen</li>
|
||||
<li>Zugriff auf alle Spaces, FQDNs und Zertifikate</li>
|
||||
<li>Möglichkeit, Berechtigungsgruppen zu verwalten</li>
|
||||
<li>Keine Einschränkungen durch Berechtigungsgruppen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">
|
||||
Möchten Sie wirklich fortfahren?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// Bei Abbrechen: Zurück zum vorherigen Wert
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
isAdmin: pendingAdminChange !== null ? pendingAdminChange : prev.isAdmin
|
||||
}))
|
||||
setPendingAdminChange(null)
|
||||
setShowAdminWarning(false)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Bei Bestätigung: isAdmin auf true setzen und Modal schließen
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
isAdmin: true,
|
||||
groupIds: [], // Gruppen entfernen wenn Admin aktiviert wird
|
||||
enabled: true // Admin muss immer enabled sein
|
||||
}))
|
||||
setPendingAdminChange(null)
|
||||
setShowAdminWarning(false)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors duration-200"
|
||||
>
|
||||
Verstanden, fortfahren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && userToDelete && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
@@ -548,6 +820,99 @@ const Users = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Enabled Confirmation Modal */}
|
||||
{showToggleModal && userToToggle && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className={`bg-slate-800 rounded-xl shadow-2xl border max-w-md w-full p-6 ${
|
||||
userToToggle.enabled ? 'border-yellow-600/50' : 'border-green-600/50'
|
||||
}`}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center mr-4 ${
|
||||
userToToggle.enabled ? 'bg-yellow-500/20' : 'bg-green-500/20'
|
||||
}`}>
|
||||
{userToToggle.enabled ? (
|
||||
<svg
|
||||
className="w-6 h-6 text-yellow-400"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-6 h-6 text-green-400"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white">
|
||||
Admin-User {userToToggle.enabled ? 'deaktivieren' : 'aktivieren'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-slate-300 mb-4">
|
||||
Möchten Sie den Admin-User <span className="font-semibold text-white">{userToToggle.username}</span> (UID: {userToToggle.id}) wirklich {userToToggle.enabled ? 'deaktivieren' : 'aktivieren'}?
|
||||
</p>
|
||||
<p className={`text-sm mb-4 ${
|
||||
userToToggle.enabled ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}>
|
||||
{userToToggle.enabled
|
||||
? 'Der Admin-User kann sich nach der Deaktivierung nicht mehr anmelden und keine API-Calls durchführen.'
|
||||
: 'Der Admin-User kann sich nach der Aktivierung wieder anmelden und API-Calls durchführen.'}
|
||||
</p>
|
||||
|
||||
<label className="flex items-start cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmToggleChecked}
|
||||
onChange={(e) => setConfirmToggleChecked(e.target.checked)}
|
||||
className={`mt-1 w-5 h-5 bg-slate-700 border-slate-600 rounded focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-800 cursor-pointer ${
|
||||
userToToggle.enabled
|
||||
? 'text-yellow-600 focus:ring-yellow-500'
|
||||
: 'text-green-600 focus:ring-green-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="ml-3 text-sm text-slate-300 group-hover:text-white transition-colors">
|
||||
Ich bestätige, dass ich den Admin-User {userToToggle.enabled ? 'deaktivieren' : 'aktivieren'} möchte
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={confirmToggle}
|
||||
disabled={!confirmToggleChecked}
|
||||
className={`flex-1 px-4 py-2 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200 ${
|
||||
userToToggle.enabled
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{userToToggle.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelToggle}
|
||||
className="flex-1 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user