Compare commits

..

16 Commits

Author SHA1 Message Date
3ea0ebae1b Merge pull request 'development' (#8) from development into main
Reviewed-on: #8
2025-11-21 23:33:13 +00:00
2f2be739f2 chore: update gitignore and stop tracking sqlite runtime files 2025-11-21 23:56:03 +01:00
d1e9c2433c added fix for empty CSR history after uploading ones
deleted:    backend/spaces.db-shm
	deleted:    backend/spaces.db-wal
	modified:   frontend/src/App.jsx
	modified:   frontend/src/components/Sidebar.jsx
	new file:   frontend/src/hooks/usePermissions.js
	modified:   frontend/src/pages/Home.jsx
	modified:   frontend/src/pages/Profile.jsx
	modified:   frontend/src/pages/SpaceDetail.jsx
2025-11-21 23:50:12 +01:00
f0c23cad35 Merge pull request 'Merge pull request 'certigo_release/1.0' from development into main' (#6) from main into development
Reviewed-on: #6
2025-11-21 22:38:00 +00:00
39148bbb56 Merge pull request 'certigo_release/1.0' from development into main
Reviewed-on: #5
2025-11-21 22:14:18 +00:00
e96fa8f367 Merge pull request 'permission missing fix when certs is empty' (#4) from fix/emptyCerts into development
Reviewed-on: #4
2025-11-21 22:05:07 +00:00
97163becfa permission missing fix when certs is empty 2025-11-21 23:04:25 +01:00
16043e2577 Merge pull request 'fix/sslProviderPermission' (#3) from fix/sslProviderPermission into development
Reviewed-on: #3
2025-11-21 01:31:07 +00:00
e3a2ccb82d fixed ssl provider section view/edit permission 2025-11-21 02:30:11 +01:00
dbb8049c7e Merge pull request 'feature/permissionsAndRoles' (#2) from feature/permissionsAndRoles into main
Reviewed-on: #2
2025-11-21 00:54:59 +00:00
24d97f6057 optimized admin protection 2025-11-21 01:52:51 +01:00
d23bfa0376 fixed permission implementation for ressources 2025-11-21 00:58:34 +01:00
0d17fda341 fixed displayname for Berechtigungsgruppen in auditlog overview 2025-11-21 00:38:48 +01:00
5523c6ff06 refined delete function for users 2025-11-21 00:33:31 +01:00
e701c2bd29 refined delete function in permission groups 2025-11-21 00:31:54 +01:00
9c2d649adf added main permission group feature 2025-11-21 00:28:53 +01:00
19 changed files with 4370 additions and 290 deletions

327
.gitignore vendored
View File

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

View File

@@ -0,0 +1,74 @@
# Passwort-Speicherung Sicherheitsanalyse
## Aktuelle Implementierung
### Wie werden Passwörter gespeichert?
1. **Algorithmus**: `bcrypt` (golang.org/x/crypto/bcrypt)
2. **Cost Factor**: `bcrypt.DefaultCost` (Wert: **10**)
3. **Speicherung**:
- Feld: `password_hash TEXT NOT NULL` in SQLite
- Format: bcrypt Hash-String (enthält automatisch Salt + Hash)
- Beispiel: `$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy`
4. **Passwortrichtlinie**:
- Mindestens 8 Zeichen
- Großbuchstaben erforderlich
- Kleinbuchstaben erforderlich
- Zahlen erforderlich
- Sonderzeichen erforderlich
5. **Validierung**:
- Altes Passwort wird bei Änderung geprüft
- `bcrypt.CompareHashAndPassword()` für Login-Validierung
## Entspricht es aktuellen Sicherheitsstandards?
### ✅ **Gut implementiert:**
1. **bcrypt ist ein sicherer, bewährter Algorithmus**
- Speziell für Passwort-Hashing entwickelt
- Verlangsamt Brute-Force-Angriffe durch anpassbare Rechenzeit
- Wird von OWASP und anderen Sicherheitsorganisationen empfohlen
2. **Automatisches Salting**
- bcrypt generiert für jedes Passwort einen eindeutigen Salt
- Verhindert Rainbow-Table-Angriffe
- Salt wird im Hash-String mitgespeichert
3. **Passwörter werden nie im Klartext gespeichert**
- Nur gehashte Werte in der Datenbank
- Einweg-Hashing (nicht reversibel)
4. **Passwortrichtlinie vorhanden**
- Erzwingt starke Passwörter
- Mindestanforderungen erfüllt
### ⚠️ **Verbesserungspotenzial:**
1. **Cost Factor könnte erhöht werden**
- **Aktuell**: Cost 10 (DefaultCost)
- **Empfohlen 2024/2025**: Cost 12-14
- **Begründung**:
- Cost 10 war vor ~10 Jahren Standard
- Moderne Hardware ist schneller
- Cost 12-14 bietet besseren Schutz gegen Brute-Force
- Trade-off: Etwas langsamere Login-Zeit (~100-500ms), aber deutlich sicherer
2. **Fehlende Sicherheitsfeatures** (optional, aber empfohlen):
- ❌ Rate Limiting für Login-Versuche (verhindert Brute-Force)
- ❌ Passwort-Historie (verhindert Wiederverwendung)
- ❌ Passwort-Ablaufzeit
- ❌ Account-Lockout nach fehlgeschlagenen Versuchen
- ❌ 2FA/MFA Support
## Empfehlung
Die aktuelle Implementierung ist **grundsätzlich sicher** und entspricht **modernen Standards**, aber:
1. **Sofort umsetzbar**: Cost Factor von 10 auf 12-14 erhöhen
2. **Mittelfristig**: Rate Limiting für Login-Versuche implementieren
3. **Langfristig**: Zusätzliche Sicherheitsfeatures (2FA, Passwort-Historie)
Soll ich den Cost Factor erhöhen?

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -1,6 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext' import { AuthProvider, useAuth } from './contexts/AuthContext'
import { PermissionsProvider } from './contexts/PermissionsContext'
import { usePermissions } from './hooks/usePermissions'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import Footer from './components/Footer' import Footer from './components/Footer'
import Home from './pages/Home' import Home from './pages/Home'
@@ -9,6 +11,8 @@ import SpaceDetail from './pages/SpaceDetail'
import Impressum from './pages/Impressum' import Impressum from './pages/Impressum'
import Profile from './pages/Profile' import Profile from './pages/Profile'
import Users from './pages/Users' import Users from './pages/Users'
import Permissions from './pages/Permissions'
import Providers from './pages/Providers'
import Login from './pages/Login' import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
@@ -33,6 +37,85 @@ const ProtectedRoute = ({ children }) => {
return isAuthenticated ? children : <Navigate to="/login" replace /> 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 }) => {
const { isAuthenticated, loading } = useAuth()
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
if (loading || permissionsLoading) {
return (
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
<div className="text-center">
<svg className="animate-spin h-12 w-12 text-blue-500 mx-auto mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-slate-300">Lade...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
if (!hasGroups) {
return (
<Navigate
to="/"
replace
state={{
message: "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.",
type: "warning"
}}
/>
)
}
return children
}
// Public Route Component (redirects to home if already logged in) // Public Route Component (redirects to home if already logged in)
const PublicRoute = ({ children }) => { const PublicRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth() const { isAuthenticated, loading } = useAuth()
@@ -66,12 +149,14 @@ const AppContent = () => {
<Routes> <Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} /> <Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} /> <Route path="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} /> <Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} /> <Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/settings/users" element={<ProtectedRoute><Users /></ProtectedRoute>} /> <Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} />
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} /> <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> </Routes>
</div> </div>
<Footer /> <Footer />
@@ -85,7 +170,9 @@ function App() {
return ( return (
<Router> <Router>
<AuthProvider> <AuthProvider>
<PermissionsProvider>
<AppContent /> <AppContent />
</PermissionsProvider>
</AuthProvider> </AuthProvider>
</Router> </Router>
) )

View File

@@ -1,19 +1,25 @@
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
const Sidebar = ({ isOpen, setIsOpen }) => { const Sidebar = ({ isOpen, setIsOpen }) => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [expandedMenus, setExpandedMenus] = useState({}) const [expandedMenus, setExpandedMenus] = useState({})
// Prüfe ob User Berechtigungsgruppen hat
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Menüpunkte - Home ist immer sichtbar, andere nur mit Gruppen
const menuItems = [ const menuItems = [
{ path: '/', label: 'Home', icon: '🏠' }, { path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
{ path: '/spaces', label: 'Spaces', icon: '📁' }, { path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
{ path: '/audit-logs', label: 'Audit Log', icon: '📋' }, { path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
{ path: '/impressum', label: 'Impressum', icon: '' }, { path: '/impressum', label: 'Impressum', icon: '', requiresGroups: true },
] ].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
// Settings mit Unterpunkten // Settings mit Unterpunkten
const settingsMenu = { const settingsMenu = {
@@ -22,6 +28,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
path: '/settings', path: '/settings',
subItems: [ subItems: [
{ path: '/settings/users', label: 'User', icon: '👥' }, { path: '/settings/users', label: 'User', icon: '👥' },
{ path: '/settings/permissions', label: 'Berechtigungen', icon: '🔐' },
{ path: '/settings/providers', label: 'SSL Provider', icon: '🔒' },
] ]
} }
@@ -127,7 +135,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</li> </li>
))} ))}
{/* Settings Menu mit Unterpunkten */} {/* Settings Menu mit Unterpunkten - nur für Admins */}
{isAdmin && (
<li> <li>
<button <button
onClick={() => isOpen && toggleMenu(settingsMenu.path)} onClick={() => isOpen && toggleMenu(settingsMenu.path)}
@@ -183,6 +192,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</ul> </ul>
)} )}
</li> </li>
)}
</ul> </ul>
{/* Profil-Eintrag und Logout am unteren Ende */} {/* Profil-Eintrag und Logout am unteren Ende */}
<div className="mt-auto pt-2 border-t border-slate-700/50 space-y-2"> <div className="mt-auto pt-2 border-t border-slate-700/50 space-y-2">

View File

@@ -0,0 +1,192 @@
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: {},
})
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()
// Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
setPermissions({
isAdmin: data.isAdmin || false,
hasFullAccess: data.hasFullAccess || false,
accessibleSpaces: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces : [],
canCreateSpace: data.permissions?.canCreateSpace || false,
canDeleteSpace: data.permissions?.canDeleteSpace || false,
canCreateFqdn: data.permissions?.canCreateFqdn || {},
canDeleteFqdn: data.permissions?.canDeleteFqdn || {},
canUploadCSR: data.permissions?.canUploadCSR || {},
canSignCSR: data.permissions?.canSignCSR || {},
})
} catch (parseErr) {
console.error('Error parsing permissions response:', parseErr)
// Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
}
} else if (response.status === 401 && isMountedRef.current) {
// Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
console.log('Unauthorized - permissions will be cleared by auth context')
}
} catch (err) {
console.error('Error fetching permissions:', err)
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
} finally {
if (isInitial && isMountedRef.current) {
setLoading(false)
}
}
}, [isAuthenticated, authFetch])
// Initiales Laden der Permissions
useEffect(() => {
if (isAuthenticated) {
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
}

View File

@@ -0,0 +1 @@
export { usePermissions } from '../contexts/PermissionsContext'

View File

@@ -163,6 +163,7 @@ const AuditLogs = () => {
csr: 'CSR', csr: 'CSR',
provider: 'Provider', provider: 'Provider',
certificate: 'Zertifikat', certificate: 'Zertifikat',
permission_group: 'Berechtigungsgruppen',
} }
const toggleLogExpansion = (logId) => { const toggleLogExpansion = (logId) => {
@@ -239,6 +240,7 @@ const AuditLogs = () => {
<option value="csr">CSR</option> <option value="csr">CSR</option>
<option value="provider">Provider</option> <option value="provider">Provider</option>
<option value="certificate">Zertifikat</option> <option value="certificate">Zertifikat</option>
<option value="permission_group">Berechtigungsgruppen</option>
</select> </select>
</div> </div>
<div> <div>

View File

@@ -1,9 +1,12 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import ProvidersSection from '../components/ProvidersSection' import { usePermissions } from '../hooks/usePermissions'
const Home = () => { const Home = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const location = useLocation()
const { isAdmin, hasFullAccess, accessibleSpaces } = usePermissions()
const [data, setData] = useState(null) const [data, setData] = useState(null)
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [loadingStats, setLoadingStats] = useState(true) const [loadingStats, setLoadingStats] = useState(true)
@@ -11,6 +14,19 @@ const Home = () => {
const intervalRef = useRef(null) const intervalRef = useRef(null)
const isMountedRef = useRef(true) const isMountedRef = useRef(true)
// Prüfe ob User Berechtigungsgruppen hat
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
const message = location.state?.message
const messageType = location.state?.type || 'info'
// Lösche location.state nach dem ersten Anzeigen
useEffect(() => {
if (location.state?.message) {
// Entferne die Nachricht aus dem state nach dem ersten Render
window.history.replaceState({}, document.title, location.pathname)
}
}, [location.state, location.pathname])
// Fetch stats function // Fetch stats function
const fetchStats = useCallback(async (isInitial = false) => { const fetchStats = useCallback(async (isInitial = false) => {
try { try {
@@ -188,7 +204,36 @@ const Home = () => {
Dies ist die Startseite der Certigo Addon Anwendung. Dies ist die Startseite der Certigo Addon Anwendung.
</p> </p>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6"> {/* Warnung wenn User keine Berechtigungsgruppen hat */}
{(!hasGroups || message) && (
<div className={`mb-6 p-4 rounded-lg border ${
messageType === 'warning'
? 'bg-yellow-500/20 border-yellow-500/50'
: 'bg-blue-500/20 border-blue-500/50'
}`}>
<div className="flex items-start gap-3">
<div className={`text-2xl flex-shrink-0 ${
messageType === 'warning' ? 'text-yellow-400' : 'text-blue-400'
}`}>
{messageType === 'warning' ? '⚠️' : ''}
</div>
<div className="flex-1">
<p className={`font-semibold mb-1 ${
messageType === 'warning' ? 'text-yellow-300' : 'text-blue-300'
}`}>
{messageType === 'warning' ? 'Keine Berechtigungsgruppe' : 'Information'}
</p>
<p className={`text-sm ${
messageType === 'warning' ? 'text-yellow-200' : 'text-blue-200'
}`}>
{message || "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator, um Zugriff auf die Anwendung zu erhalten."}
</p>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Stats Dashboard */} {/* Stats Dashboard */}
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6"> <div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -306,9 +351,6 @@ const Home = () => {
<p className="text-slate-400">Lade Daten...</p> <p className="text-slate-400">Lade Daten...</p>
)} )}
</div> </div>
{/* SSL Certificate Providers */}
<ProvidersSection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,637 @@
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)
const [fetching, setFetching] = useState(true)
const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingGroup, setEditingGroup] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [groupToDelete, setGroupToDelete] = useState(null)
const [confirmChecked, setConfirmChecked] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
permission: 'READ',
spaceIds: []
})
useEffect(() => {
// Lade Daten parallel beim Mount und beim Zurückkehren zur Seite
let isMounted = true
const loadData = async () => {
if (isMounted) {
setFetching(true)
}
await Promise.all([fetchGroups(), fetchSpaces()])
}
loadData()
return () => {
isMounted = false
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const fetchGroups = async () => {
try {
setError('')
const response = await authFetch('/api/permission-groups')
if (response.ok) {
const data = await response.json()
setGroups(Array.isArray(data) ? data : [])
} else {
setError('Fehler beim Abrufen der Berechtigungsgruppen')
}
} catch (err) {
setError('Fehler beim Abrufen der Berechtigungsgruppen')
console.error('Error fetching permission groups:', err)
} finally {
setFetching(false)
}
}
const fetchSpaces = async () => {
try {
const response = await authFetch('/api/spaces')
if (response.ok) {
const data = await response.json()
setSpaces(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error fetching spaces:', err)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
if (!formData.name.trim()) {
setError('Bitte geben Sie einen Namen ein')
setLoading(false)
return
}
if (!formData.permission) {
setError('Bitte wählen Sie eine Berechtigungsstufe')
setLoading(false)
return
}
try {
const url = editingGroup
? `/api/permission-groups/${editingGroup.id}`
: '/api/permission-groups'
const method = editingGroup ? 'PUT' : 'POST'
const body = {
name: formData.name,
description: formData.description,
permission: formData.permission,
spaceIds: formData.spaceIds
}
const response = await authFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (response.ok) {
await fetchGroups()
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')
}
} catch (err) {
setError('Fehler beim Speichern der Berechtigungsgruppe')
console.error('Error saving permission group:', err)
} finally {
setLoading(false)
}
}
const handleEdit = (group) => {
setEditingGroup(group)
setFormData({
name: group.name,
description: group.description || '',
permission: group.permission,
spaceIds: group.spaceIds || []
})
setShowForm(true)
}
const handleDelete = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
setConfirmChecked(false)
}
const confirmDelete = async () => {
if (!confirmChecked || !groupToDelete) {
return
}
try {
const response = await authFetch(`/api/permission-groups/${groupToDelete.id}`, {
method: 'DELETE',
})
if (response.ok) {
await fetchGroups()
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')
}
} catch (err) {
console.error('Error deleting permission group:', err)
alert('Fehler beim Löschen der Berechtigungsgruppe')
}
}
const cancelDelete = () => {
setShowDeleteModal(false)
setGroupToDelete(null)
setConfirmChecked(false)
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSpaceToggle = (spaceId) => {
setFormData(prev => {
const spaceIds = prev.spaceIds || []
if (spaceIds.includes(spaceId)) {
return { ...prev, spaceIds: spaceIds.filter(id => id !== spaceId) }
} else {
return { ...prev, spaceIds: [...spaceIds, spaceId] }
}
})
}
const getPermissionLabel = (permission) => {
switch (permission) {
case 'READ':
return 'Lesen'
case 'READ_WRITE':
return 'Lesen/Schreiben'
case 'FULL_ACCESS':
return 'Vollzugriff'
default:
return permission
}
}
const getPermissionBadgeColor = (permission) => {
switch (permission) {
case 'READ':
return 'bg-green-600/20 text-green-300 border-green-500/30'
case 'READ_WRITE':
return 'bg-yellow-600/20 text-yellow-300 border-yellow-500/30'
case 'FULL_ACCESS':
return 'bg-purple-600/20 text-purple-300 border-purple-500/30'
default:
return 'bg-blue-600/20 text-blue-300 border-blue-500/30'
}
}
const getPermissionIcon = (permission) => {
switch (permission) {
case 'READ':
return '👁️'
case 'READ_WRITE':
return '✏️'
case 'FULL_ACCESS':
return '🔓'
default:
return '🔐'
}
}
const getPermissionDescription = (permission) => {
switch (permission) {
case 'READ':
return 'Nur CSRs und Zertifikate ansehen. Keine Requests, keine Lösch-/Erstellrechte.'
case 'READ_WRITE':
return 'FQDNs innerhalb eines Spaces erstellen (nicht löschen), CSRs requesten und ansehen. Keine Spaces löschen/erstellen.'
case 'FULL_ACCESS':
return 'Vollzugriff: Alles darf gemacht werden. Löschen, Erstellen, CSR requesten und ansehen.'
default:
return ''
}
}
return (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-4xl font-bold text-white mb-2 flex items-center gap-3">
<span className="text-5xl">🔐</span>
Berechtigungsgruppen
</h1>
<p className="text-slate-300">Verwalten Sie Berechtigungsgruppen und weisen Sie Spaces zu</p>
</div>
<button
onClick={() => {
setShowForm(true)
setEditingGroup(null)
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
}}
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 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Neue Gruppe
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300">
{error}
</div>
)}
{showForm && (
<div className="mb-8 bg-slate-800/80 backdrop-blur-sm rounded-xl shadow-2xl border border-slate-600/50 p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<span className="text-3xl">{editingGroup ? '✏️' : ''}</span>
{editingGroup ? 'Berechtigungsgruppe bearbeiten' : 'Neue Berechtigungsgruppe'}
</h2>
<button
onClick={() => {
setShowForm(false)
setEditingGroup(null)
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
}}
className="text-slate-400 hover:text-white transition-colors"
title="Schließen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2 flex items-center gap-2">
<span>📝</span>
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-4 py-2.5 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="z.B. Entwickler, Administratoren"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2 flex items-center gap-2">
<span>📄</span>
Beschreibung
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows="3"
className="w-full px-4 py-2.5 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 resize-none"
placeholder="Beschreibung der Berechtigungsgruppe"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-3 flex items-center gap-2">
<span>🔑</span>
Berechtigungsstufe *
</label>
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'READ', label: 'Lesen', icon: '👁️', activeClass: 'bg-green-600/20 border-green-500 text-green-300', inactiveClass: 'bg-slate-700/50 border-slate-600 text-slate-300' },
{ value: 'READ_WRITE', label: 'Lesen/Schreiben', icon: '✏️', activeClass: 'bg-yellow-600/20 border-yellow-500 text-yellow-300', inactiveClass: 'bg-slate-700/50 border-slate-600 text-slate-300' },
{ value: 'FULL_ACCESS', label: 'Vollzugriff', icon: '🔓', activeClass: 'bg-purple-600/20 border-purple-500 text-purple-300', inactiveClass: 'bg-slate-700/50 border-slate-600 text-slate-300' }
].map(option => (
<button
key={option.value}
type="button"
onClick={() => setFormData({ ...formData, permission: option.value })}
className={`p-4 rounded-lg border-2 transition-all duration-200 ${
formData.permission === option.value
? `${option.activeClass} shadow-lg`
: `${option.inactiveClass} hover:border-slate-500`
}`}
>
<div className="text-2xl mb-2">{option.icon}</div>
<div className="font-semibold text-sm">{option.label}</div>
</button>
))}
</div>
<div className="mt-3 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
<p className="text-xs text-slate-400 leading-relaxed">
{getPermissionDescription(formData.permission)}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-2 flex items-center gap-2">
<span>📁</span>
Spaces zuweisen
{formData.spaceIds && formData.spaceIds.length > 0 && (
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 rounded-full text-xs font-medium">
{formData.spaceIds.length} ausgewählt
</span>
)}
</label>
<div className="bg-slate-700/50 border border-slate-600 rounded-lg p-4 max-h-60 overflow-y-auto">
{spaces.length === 0 ? (
<div className="text-center py-8">
<p className="text-slate-400 text-sm">Keine Spaces vorhanden</p>
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{spaces.map(space => (
<label
key={space.id}
className={`flex items-center cursor-pointer p-3 rounded-lg border transition-all duration-200 ${
formData.spaceIds?.includes(space.id)
? 'bg-blue-600/20 border-blue-500/50 hover:bg-blue-600/30'
: 'bg-slate-600/30 border-slate-600 hover:bg-slate-600/50 hover:border-slate-500'
}`}
>
<input
type="checkbox"
checked={formData.spaceIds?.includes(space.id) || false}
onChange={() => handleSpaceToggle(space.id)}
className="w-5 h-5 text-blue-600 bg-slate-700 border-slate-500 rounded focus:ring-blue-500 focus:ring-2"
/>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2">
<span className="text-slate-300 font-medium">{space.name}</span>
{formData.spaceIds?.includes(space.id) && (
<span className="text-blue-400"></span>
)}
</div>
{space.description && (
<p className="text-xs text-slate-400 mt-0.5">{space.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
</div>
</div>
<div className="flex gap-3 mt-6 pt-6 border-t border-slate-600/50">
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 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 flex items-center gap-2 shadow-lg hover:shadow-xl"
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wird gespeichert...
</>
) : (
<>
<span>{editingGroup ? '💾' : ''}</span>
{editingGroup ? 'Aktualisieren' : 'Erstellen'}
</>
)}
</button>
<button
type="button"
onClick={() => {
setShowForm(false)
setEditingGroup(null)
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
}}
className="px-6 py-2.5 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-all duration-200"
>
Abbrechen
</button>
</div>
</form>
</div>
)}
{fetching ? (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-8 text-center">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-8 w-8 text-blue-500 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 Berechtigungsgruppen...</p>
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-xl shadow-xl border border-slate-600/50 p-12 text-center">
<div className="text-6xl mb-4">🔐</div>
<p className="text-slate-300 text-lg mb-2">
Noch keine Berechtigungsgruppen vorhanden
</p>
<p className="text-slate-400 text-sm mb-6">
Erstellen Sie Ihre erste Gruppe, um Berechtigungen zu verwalten
</p>
<button
onClick={() => {
setShowForm(true)
setEditingGroup(null)
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
}}
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"
>
+ Erste Gruppe erstellen
</button>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{groups.map((group) => (
<div
key={group.id}
className="bg-slate-800/80 backdrop-blur-sm rounded-xl shadow-xl border border-slate-600/50 p-6 hover:border-slate-500/50 transition-all duration-200 group"
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-xl font-bold text-white group-hover:text-blue-300 transition-colors mb-2">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-slate-400 mb-2">{group.description}</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleEdit(group)}
className="p-2 bg-blue-600/20 hover:bg-blue-600/30 text-blue-300 rounded-lg transition-all duration-200"
title="Bearbeiten"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(group)}
className="p-2 bg-red-600/20 hover:bg-red-600/30 text-red-300 rounded-lg transition-all duration-200"
title="Löschen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm font-medium mb-4 ${getPermissionBadgeColor(group.permission)}`}>
<span className="text-lg">{getPermissionIcon(group.permission)}</span>
{getPermissionLabel(group.permission)}
</div>
{group.spaceIds && group.spaceIds.length > 0 && (
<div className="mt-4 pt-4 border-t border-slate-600/50">
<p className="text-sm font-medium text-slate-300 mb-2 flex items-center gap-2">
<span>📁</span>
Zugewiesene Spaces ({group.spaceIds.length})
</p>
<div className="flex flex-wrap gap-2">
{group.spaceIds.map(spaceId => {
const space = spaces.find(s => s.id === spaceId)
return space ? (
<span
key={spaceId}
className="px-3 py-1.5 bg-slate-700/50 text-slate-300 rounded-lg text-xs font-medium border border-slate-600/50 hover:border-slate-500 transition-colors"
>
{space.name}
</span>
) : null
})}
</div>
</div>
)}
<div className="mt-4 pt-3 border-t border-slate-600/50">
<p className="text-xs text-slate-500">
Erstellt: {group.createdAt ? new Date(group.createdAt).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) : 'Unbekannt'}
</p>
</div>
</div>
))}
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<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-slate-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">
Berechtigungsgruppe löschen
</h3>
</div>
<div className="mb-6">
<p className="text-slate-300 mb-4">
Möchten Sie die Berechtigungsgruppe <span className="font-semibold text-white">{groupToDelete.name}</span> wirklich löschen?
</p>
<p className="text-sm text-red-400 mb-4">
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<label className="flex items-start cursor-pointer group">
<input
type="checkbox"
checked={confirmChecked}
onChange={(e) => setConfirmChecked(e.target.checked)}
className="mt-1 w-5 h-5 text-red-600 bg-slate-700 border-slate-600 rounded focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-slate-800 cursor-pointer"
/>
<span className="ml-3 text-sm text-slate-300 group-hover:text-white transition-colors">
Ich bestätige, dass ich diese Berechtigungsgruppe unwiderruflich löschen möchte
</span>
</label>
</div>
<div className="flex gap-3">
<button
onClick={confirmDelete}
disabled={!confirmChecked}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
>
Löschen
</button>
<button
onClick={cancelDelete}
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>
)
}
export default Permissions

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../hooks/usePermissions'
const Profile = () => { const Profile = () => {
const { authFetch, user } = useAuth() const { authFetch, user } = useAuth()
const { isAdmin } = usePermissions()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showSuccessAnimation, setShowSuccessAnimation] = useState(false) const [showSuccessAnimation, setShowSuccessAnimation] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -286,8 +288,10 @@ const Profile = () => {
try { try {
const body = { const body = {
...(formData.username && { username: formData.username }), // Nur der spezielle Admin-User mit UID 'admin': Username und Email nicht ändern
...(formData.email && { email: formData.email }), // 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 && { ...(formData.password && {
password: formData.password, password: formData.password,
oldPassword: formData.oldPassword oldPassword: formData.oldPassword
@@ -414,9 +418,15 @@ const Profile = () => {
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
required 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" 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>
<div> <div>
@@ -430,9 +440,15 @@ const Profile = () => {
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
required 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" 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>
<div className="pt-4 border-t border-slate-700/50"> <div className="pt-4 border-t border-slate-700/50">

View File

@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
const Providers = () => {
const { authFetch } = useAuth()
const [providers, setProviders] = useState([])
const [loading, setLoading] = useState(true)
const [showConfigModal, setShowConfigModal] = useState(false)
const [selectedProvider, setSelectedProvider] = useState(null)
const [configValues, setConfigValues] = useState({})
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState(null)
useEffect(() => {
fetchProviders()
}, [authFetch])
const fetchProviders = async () => {
try {
const response = await authFetch('/api/providers')
if (response.ok) {
const data = await response.json()
// Definiere feste Reihenfolge der Provider
const providerOrder = ['dummy-ca', 'autodns', 'hetzner']
const sortedProviders = providerOrder
.map(id => data.find(p => p.id === id))
.filter(p => p !== undefined)
.concat(data.filter(p => !providerOrder.includes(p.id)))
setProviders(sortedProviders)
}
} catch (err) {
console.error('Error fetching providers:', err)
} finally {
setLoading(false)
}
}
const handleToggleProvider = async (providerId, currentEnabled) => {
try {
const response = await authFetch(`/api/providers/${providerId}/enabled`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ enabled: !currentEnabled }),
})
if (response.ok) {
fetchProviders()
} else {
alert('Fehler beim Ändern des Provider-Status')
}
} catch (err) {
console.error('Error toggling provider:', err)
alert('Fehler beim Ändern des Provider-Status')
}
}
const handleOpenConfig = async (provider) => {
setSelectedProvider(provider)
setTestResult(null)
// Lade aktuelle Konfiguration
try {
const response = await authFetch(`/api/providers/${provider.id}`)
if (response.ok) {
const data = await response.json()
// Initialisiere Config-Werte
const initialValues = {}
provider.settings.forEach(setting => {
if (data.config && data.config[setting.name] !== undefined) {
// Wenn Wert "***" ist, bedeutet das, dass es ein Passwort ist - leer lassen
initialValues[setting.name] = data.config[setting.name] === '***' ? '' : data.config[setting.name]
} else {
initialValues[setting.name] = setting.default || ''
}
})
setConfigValues(initialValues)
}
} catch (err) {
console.error('Error fetching provider config:', err)
// Initialisiere mit leeren Werten
const initialValues = {}
provider.settings.forEach(setting => {
initialValues[setting.name] = setting.default || ''
})
setConfigValues(initialValues)
}
setShowConfigModal(true)
}
const handleCloseConfig = () => {
setShowConfigModal(false)
setSelectedProvider(null)
setConfigValues({})
setTestResult(null)
}
const handleConfigChange = (name, value) => {
setConfigValues({
...configValues,
[name]: value,
})
}
const handleTestConnection = async () => {
if (!selectedProvider) return
setTesting(true)
setTestResult(null)
try {
const response = await authFetch(`/api/providers/${selectedProvider.id}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings: configValues }),
})
const result = await response.json()
setTestResult(result)
} catch (err) {
console.error('Error testing connection:', err)
setTestResult({
success: false,
message: 'Fehler beim Testen der Verbindung',
})
} finally {
setTesting(false)
}
}
const handleSaveConfig = async () => {
if (!selectedProvider) return
try {
const response = await authFetch(`/api/providers/${selectedProvider.id}/config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ settings: configValues }),
})
if (response.ok) {
handleCloseConfig()
fetchProviders()
} else {
const error = await response.text()
alert(`Fehler beim Speichern: ${error}`)
}
} catch (err) {
console.error('Error saving config:', err)
alert('Fehler beim Speichern der Konfiguration')
}
}
return (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-bold text-white mb-4">SSL Certificate Providers</h1>
<p className="text-lg text-slate-200 mb-8">
Verwalten Sie Ihre SSL-Zertifikats-Provider und deren Konfiguration.
</p>
{loading ? (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<p className="text-slate-400">Lade Provider...</p>
</div>
) : (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.id}
className="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50 transition-all duration-300"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-white mb-1 transition-colors duration-300">
{provider.displayName}
</h3>
<p className="text-sm text-slate-300 mb-2 transition-colors duration-300">
{provider.description}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleOpenConfig(provider)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition-colors"
title="Konfiguration"
aria-label="Konfiguration"
>
<svg
className="w-5 h-5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.enabled}
onChange={() => handleToggleProvider(provider.id, provider.enabled)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 transition-all duration-300"></div>
</label>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Configuration Modal */}
{showConfigModal && selectedProvider && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-colors duration-300">
<div className="bg-slate-800 rounded-xl shadow-2xl border border-slate-600/50 max-w-2xl w-full p-6 transition-all duration-300">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white transition-colors duration-300">
{selectedProvider.displayName} - Konfiguration
</h3>
<button
onClick={handleCloseConfig}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700/50 rounded-lg transition-colors"
aria-label="Schließen"
>
<svg
className="w-5 h-5"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4 mb-6">
{selectedProvider.settings.length > 0 ? (
selectedProvider.settings.map((setting) => (
<div key={setting.name}>
<label className="block text-sm font-medium text-slate-200 mb-2 transition-colors duration-300">
{setting.label}
{setting.required && <span className="text-red-400 ml-1">*</span>}
</label>
{setting.description && (
<p className="text-xs text-slate-400 mb-2 transition-colors duration-300">{setting.description}</p>
)}
{setting.type === 'password' ? (
<input
type="password"
value={configValues[setting.name] || ''}
onChange={(e) => handleConfigChange(setting.name, e.target.value)}
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-300"
placeholder={setting.label}
required={setting.required}
/>
) : (
<input
type={setting.type || 'text'}
value={configValues[setting.name] || ''}
onChange={(e) => handleConfigChange(setting.name, e.target.value)}
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-300"
placeholder={setting.label}
required={setting.required}
/>
)}
</div>
))
) : (
<p className="text-slate-300 text-center py-4 transition-colors duration-300">
Dieser Provider benötigt keine Konfiguration.
</p>
)}
</div>
{testResult && (
<div
className={`mb-4 p-4 rounded-lg border ${
testResult.success
? 'bg-green-500/20 border-green-500/50'
: 'bg-red-500/20 border-red-500/50'
}`}
>
<p
className={`text-sm ${
testResult.success ? 'text-green-300' : 'text-red-300'
}`}
>
{testResult.success ? '✅' : '❌'} {testResult.message}
</p>
</div>
)}
<div className="flex gap-3">
{selectedProvider.settings.length > 0 && (
<button
onClick={handleTestConnection}
disabled={testing}
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200"
>
{testing ? 'Teste...' : 'Verbindung testen'}
</button>
)}
<button
onClick={handleSaveConfig}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200"
>
Speichern
</button>
<button
onClick={handleCloseConfig}
className="px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default Providers

View File

@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../contexts/PermissionsContext'
const SpaceDetail = () => { const SpaceDetail = () => {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { authFetch } = useAuth() const { authFetch } = useAuth()
const { canCreateFqdn, canDeleteFqdn, canSignCSR, canUploadCSR, refreshPermissions } = usePermissions()
const [space, setSpace] = useState(null) const [space, setSpace] = useState(null)
const [fqdns, setFqdns] = useState([]) const [fqdns, setFqdns] = useState([])
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
@@ -123,6 +125,8 @@ const SpaceDetail = () => {
setFqdns([...fqdns, newFqdn]) setFqdns([...fqdns, newFqdn])
setFormData({ fqdn: '', description: '' }) setFormData({ fqdn: '', description: '' })
setShowForm(false) setShowForm(false)
// Aktualisiere Berechtigungen nach dem Erstellen eines FQDNs
refreshPermissions()
} else { } else {
let errorMessage = 'Fehler beim Erstellen des FQDN' let errorMessage = 'Fehler beim Erstellen des FQDN'
try { try {
@@ -176,6 +180,8 @@ const SpaceDetail = () => {
setShowDeleteModal(false) setShowDeleteModal(false)
setFqdnToDelete(null) setFqdnToDelete(null)
setConfirmChecked(false) setConfirmChecked(false)
// Aktualisiere Berechtigungen nach dem Löschen eines FQDNs
refreshPermissions()
} else { } else {
const errorData = await response.json().catch(() => ({ error: 'Fehler beim Löschen' })) const errorData = await response.json().catch(() => ({ error: 'Fehler beim Löschen' }))
alert(errorData.error || 'Fehler beim Löschen des FQDN') alert(errorData.error || 'Fehler beim Löschen des FQDN')
@@ -226,18 +232,50 @@ const SpaceDetail = () => {
if (response.ok) { if (response.ok) {
const csr = await response.json() const csr = await response.json()
// Füge den neuen CSR zur History hinzu (nur wenn der Bereich bereits geöffnet ist)
if (showCSRDropdown[fqdn.id]) {
const newCsrWithFqdnId = { ...csr, fqdnId: fqdn.id }
setCsrHistory(prev => {
const filtered = prev.filter(csrItem => csrItem.fqdnId !== fqdn.id)
// Füge den neuen CSR am Anfang hinzu (neuester zuerst)
return [newCsrWithFqdnId, ...filtered]
})
}
setCsrData(csr) setCsrData(csr)
setSelectedFqdn(fqdn) setSelectedFqdn(fqdn)
// Lade die komplette CSR History neu, um den neuen CSR anzuzeigen
// WICHTIG: Warte auf die History bevor der Dropdown geöffnet wird
try {
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
if (historyResponse.ok) {
const history = await historyResponse.json()
// Stelle sicher, dass history ein Array ist
const historyArray = Array.isArray(history) ? history : []
// Füge fqdnId zu jedem CSR hinzu und stelle sicher dass sie immer gesetzt ist
// Auch wenn die API fqdnId bereits zurückgibt, überschreiben wir sie mit fqdn.id für Konsistenz
// Stelle sicher dass alle CSRs gültig sind und fqdnId gesetzt ist
const historyWithFqdnId = historyArray
.filter(csrItem => csrItem && csrItem.id) // Stelle sicher dass CSR gültig ist
.map(csrItem => ({
...csrItem,
fqdnId: String(fqdn.id) // Immer als String für konsistente Filterung
}))
setCsrHistory(prev => {
// Entferne alte CSRs für diesen FQDN und füge die neuen hinzu
// Verwende String-Vergleich für Robustheit
const filtered = prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId]
})
// Öffne den CSR History Dropdown NACH dem Laden der History
setShowCSRDropdown(prev => ({ ...prev, [fqdn.id]: true }))
} else {
setCsrHistory(prev => {
// Bei Fehler, entferne nur CSRs für diesen FQDN, behalte andere
return prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id))
})
}
} catch (err) {
console.error('Error fetching CSR history after upload:', err)
// Bei Fehler, entferne nur CSRs für diesen FQDN
setCsrHistory(prev => prev.filter(csrItem => String(csrItem?.fqdnId) !== String(fqdn.id)))
}
setShowCSRModal(true) setShowCSRModal(true)
// Aktualisiere die FQDN-Liste // Aktualisiere die FQDN-Liste
@@ -292,14 +330,23 @@ const SpaceDetail = () => {
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`) const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
if (historyResponse.ok) { if (historyResponse.ok) {
const history = await historyResponse.json() const history = await historyResponse.json()
setCsrHistory(Array.isArray(history) ? history : []) const historyArray = Array.isArray(history) ? history : []
// Stelle sicher dass fqdnId gesetzt ist für konsistente Filterung
const historyWithFqdnId = historyArray
.filter(csr => csr && csr.id)
.map(csr => ({ ...csr, fqdnId: String(fqdn.id) }))
setCsrHistory(prev => {
const filtered = prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId]
})
} else { } else {
setCsrHistory([]) setCsrHistory(prev => prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id)))
} }
} catch (err) { } catch (err) {
console.error('Error fetching CSR:', err) console.error('Error fetching CSR:', err)
setCsrData(null) setCsrData(null)
setCsrHistory([]) // Entferne nur CSRs für diesen FQDN, behalte andere
setCsrHistory(prev => prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id)))
} }
} }
@@ -313,7 +360,7 @@ const SpaceDetail = () => {
setSelectedFqdn(null) setSelectedFqdn(null)
setCsrData(null) setCsrData(null)
setCsrError('') setCsrError('')
setCsrHistory([]) // csrHistory NICHT zurücksetzen - bleibt für Dropdown-Anzeige erhalten
} }
const handleChange = (e) => { const handleChange = (e) => {
@@ -417,33 +464,54 @@ const SpaceDetail = () => {
} }
const handleViewCertificates = async (fqdn) => { const handleViewCertificates = async (fqdn) => {
if (!fqdn || !fqdn.id) {
console.error('Invalid FQDN provided to handleViewCertificates')
return
}
setSelectedFqdn(fqdn) setSelectedFqdn(fqdn)
setLoadingCertificates(true) setLoadingCertificates(true)
setCertificates([]) setCertificates([])
setShowCertificatesModal(true) // Öffne Modal sofort, auch wenn noch geladen wird
try { try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`) const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
if (response.ok) { if (response.ok) {
try {
const certs = await response.json() const certs = await response.json()
setCertificates(certs) // Stelle sicher, dass certs ein Array ist
setCertificates(Array.isArray(certs) ? certs : [])
} catch (parseErr) {
console.error('Error parsing certificates response:', parseErr)
setCertificates([])
}
} else { } else {
console.error('Fehler beim Laden der Zertifikate') // Bei Fehler-Response (404, 403, etc.) setze leeres Array
console.error('Fehler beim Laden der Zertifikate:', response.status, response.statusText)
setCertificates([])
} }
} catch (err) { } catch (err) {
console.error('Error fetching certificates:', err) console.error('Error fetching certificates:', err)
setCertificates([])
} finally { } finally {
setLoadingCertificates(false) setLoadingCertificates(false)
setShowCertificatesModal(true)
} }
} }
const handleRefreshCertificate = async (cert) => { const handleRefreshCertificate = async (cert) => {
if (!cert || !cert.id || !selectedFqdn || !selectedFqdn.id) {
console.error('Invalid certificate or FQDN for refresh')
return
}
setRefreshingCertificate(cert.id) setRefreshingCertificate(cert.id)
try { try {
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, { const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates/${cert.id}/refresh`, {
method: 'POST' method: 'POST'
}) })
if (response.ok) { if (response.ok) {
try {
const result = await response.json() const result = await response.json()
// Aktualisiere Zertifikat in der Liste // Aktualisiere Zertifikat in der Liste
setCertificates(prev => prev.map(c => setCertificates(prev => prev.map(c =>
@@ -451,6 +519,9 @@ const SpaceDetail = () => {
? { ...c, certificatePEM: result.certificatePEM } ? { ...c, certificatePEM: result.certificatePEM }
: c : c
)) ))
} catch (parseErr) {
console.error('Error parsing refresh response:', parseErr)
}
} }
} catch (err) { } catch (err) {
console.error('Error refreshing certificate:', err) console.error('Error refreshing certificate:', err)
@@ -586,7 +657,13 @@ const SpaceDetail = () => {
</h3> </h3>
<button <button
onClick={() => setShowForm(!showForm)} onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-200" disabled={!canCreateFqdn(id) && !showForm}
className={`px-4 py-2 font-semibold rounded-lg transition-all duration-200 ${
canCreateFqdn(id) || showForm
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-slate-600 text-slate-400 cursor-not-allowed opacity-50'
}`}
title={!canCreateFqdn(id) && !showForm ? 'Keine Berechtigung zum Erstellen von FQDNs' : ''}
> >
{showForm ? 'Abbrechen' : '+ Neuer FQDN'} {showForm ? 'Abbrechen' : '+ Neuer FQDN'}
</button> </button>
@@ -630,11 +707,18 @@ const SpaceDetail = () => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (canSignCSR(id)) {
handleRequestSigning(fqdn) handleRequestSigning(fqdn)
}
}} }}
className="p-2 text-purple-400 hover:text-purple-300 hover:bg-purple-500/20 rounded-lg transition-colors" disabled={!canSignCSR(id)}
title="CSR signieren lassen" className={`p-2 rounded-lg transition-colors ${
aria-label="CSR signieren lassen" canSignCSR(id)
? 'text-purple-400 hover:text-purple-300 hover:bg-purple-500/20'
: 'text-slate-500 cursor-not-allowed opacity-50'
}`}
title={canSignCSR(id) ? 'CSR signieren lassen' : 'Keine Berechtigung zum Signieren von CSRs'}
aria-label={canSignCSR(id) ? 'CSR signieren lassen' : 'Keine Berechtigung'}
> >
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@@ -692,16 +776,23 @@ const SpaceDetail = () => {
} }
e.target.value = '' e.target.value = ''
}} }}
disabled={uploadingCSR} disabled={uploadingCSR || !canUploadCSR(id)}
/> />
<button <button
className="p-2 text-blue-400 hover:text-blue-300 hover:bg-blue-500/20 rounded-lg transition-colors" disabled={!canUploadCSR(id)}
title="CSR hochladen" className={`p-2 rounded-lg transition-colors ${
aria-label="CSR hochladen" canUploadCSR(id)
? 'text-blue-400 hover:text-blue-300 hover:bg-blue-500/20'
: 'text-slate-500 cursor-not-allowed opacity-50'
}`}
title={canUploadCSR(id) ? 'CSR hochladen' : 'Keine Berechtigung zum Hochladen von CSRs'}
aria-label={canUploadCSR(id) ? 'CSR hochladen' : 'Keine Berechtigung'}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
if (canUploadCSR(id)) {
e.currentTarget.parentElement.querySelector('input[type="file"]')?.click() e.currentTarget.parentElement.querySelector('input[type="file"]')?.click()
}
}} }}
> >
<svg <svg
@@ -772,11 +863,12 @@ const SpaceDetail = () => {
if (response.ok) { if (response.ok) {
const history = await response.json() const history = await response.json()
// Speichere History mit FQDN-ID als Key // Speichere History mit FQDN-ID als Key
const historyWithFqdnId = Array.isArray(history) const historyArray = Array.isArray(history) ? history : []
? history.map(csr => ({ ...csr, fqdnId: fqdn.id })) const historyWithFqdnId = historyArray
: [] .filter(csr => csr && csr.id)
.map(csr => ({ ...csr, fqdnId: String(fqdn.id) }))
setCsrHistory(prev => { setCsrHistory(prev => {
const filtered = prev.filter(csr => csr.fqdnId !== fqdn.id) const filtered = prev.filter(csr => String(csr?.fqdnId) !== String(fqdn.id))
return [...filtered, ...historyWithFqdnId] return [...filtered, ...historyWithFqdnId]
}) })
} }
@@ -806,11 +898,18 @@ const SpaceDetail = () => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (canDeleteFqdn(id)) {
handleDelete(fqdn) handleDelete(fqdn)
}
}} }}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/20 rounded-lg transition-colors" disabled={!canDeleteFqdn(id)}
title="FQDN löschen" className={`p-2 rounded-lg transition-colors ${
aria-label="FQDN löschen" canDeleteFqdn(id)
? 'text-red-400 hover:text-red-300 hover:bg-red-500/20'
: 'text-slate-500 cursor-not-allowed opacity-50'
}`}
title={canDeleteFqdn(id) ? 'FQDN löschen' : 'Keine Berechtigung zum Löschen von FQDNs'}
aria-label={canDeleteFqdn(id) ? 'FQDN löschen' : 'Keine Berechtigung'}
> >
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@@ -833,12 +932,13 @@ const SpaceDetail = () => {
<div className="border-t border-slate-600/50 bg-slate-800/50 p-4"> <div className="border-t border-slate-600/50 bg-slate-800/50 p-4">
<h5 className="text-sm font-semibold text-slate-300 mb-3">CSR History</h5> <h5 className="text-sm font-semibold text-slate-300 mb-3">CSR History</h5>
{(() => { {(() => {
// Filtere CSRs für diesen FQDN - verwende String-Vergleich für Robustheit
const fqdnHistory = csrHistory const fqdnHistory = csrHistory
.filter(csr => csr.fqdnId === fqdn.id) .filter(csr => csr && String(csr.fqdnId) === String(fqdn.id))
.sort((a, b) => { .sort((a, b) => {
// Sortiere nach created_at, neueste zuerst // Sortiere nach created_at, neueste zuerst
const dateA = new Date(a.createdAt).getTime() const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = new Date(b.createdAt).getTime() const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA return dateB - dateA
}) })
return fqdnHistory.length > 0 ? ( return fqdnHistory.length > 0 ? (
@@ -1445,10 +1545,13 @@ const SpaceDetail = () => {
<div className="bg-slate-800 rounded-lg border border-slate-600 max-w-4xl w-full max-h-[90vh] overflow-y-auto"> <div className="bg-slate-800 rounded-lg border border-slate-600 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6"> <div className="p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">Zertifikate für {selectedFqdn.fqdn}</h2> <h2 className="text-2xl font-bold text-white">
Zertifikate für {selectedFqdn?.fqdn || 'Unbekannter FQDN'}
</h2>
<button <button
onClick={closeCertificatesModal} onClick={closeCertificatesModal}
className="text-slate-400 hover:text-white transition-colors" className="text-slate-400 hover:text-white transition-colors"
aria-label="Schließen"
> >
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -1468,64 +1571,80 @@ const SpaceDetail = () => {
{certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden {certificates.length} {certificates.length === 1 ? 'Zertifikat' : 'Zertifikate'} gefunden
</p> </p>
</div> </div>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => {
<div key={cert.id} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600"> // Sicherstellen, dass cert ein gültiges Objekt ist
if (!cert || !cert.id) {
return null
}
const certId = cert.id || 'unknown'
const certCertificateId = cert.certificateId || 'N/A'
const certCreatedAt = cert.createdAt ? new Date(cert.createdAt) : null
const certStatus = cert.status || 'unknown'
const certProviderId = cert.providerId
const certPEM = cert.certificatePEM
return (
<div key={certId} className="bg-slate-700/30 rounded-lg p-4 border border-slate-600">
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-purple-400 bg-purple-500/20 px-2 py-1 rounded"> <span className="text-xs font-semibold text-purple-400 bg-purple-500/20 px-2 py-1 rounded">
#{certificates.length - index} #{certificates.length - index}
</span> </span>
<h4 className="text-white font-semibold">CA-Zertifikat-ID: {cert.certificateId}</h4> <h4 className="text-white font-semibold">CA-Zertifikat-ID: {certCertificateId}</h4>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-slate-400 text-xs"> <p className="text-slate-400 text-xs">
<span className="font-semibold text-slate-300">Interne UID:</span>{' '} <span className="font-semibold text-slate-300">Interne UID:</span>{' '}
<span className="font-mono text-xs">{cert.id}</span> <span className="font-mono text-xs">{certId}</span>
</p> </p>
{certCreatedAt && !isNaN(certCreatedAt.getTime()) && (
<p className="text-slate-400 text-sm"> <p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Erstellt:</span>{' '} <span className="font-semibold text-slate-300">Erstellt:</span>{' '}
{new Date(cert.createdAt).toLocaleString('de-DE')} {certCreatedAt.toLocaleString('de-DE')}
</p> </p>
)}
<p className="text-slate-400 text-sm"> <p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Status:</span>{' '} <span className="font-semibold text-slate-300">Status:</span>{' '}
<span className={`inline-block px-2 py-0.5 rounded text-xs ${ <span className={`inline-block px-2 py-0.5 rounded text-xs ${
cert.status === 'issued' certStatus === 'issued'
? 'bg-green-500/20 text-green-400' ? 'bg-green-500/20 text-green-400'
: cert.status === 'pending' : certStatus === 'pending'
? 'bg-yellow-500/20 text-yellow-400' ? 'bg-yellow-500/20 text-yellow-400'
: 'bg-red-500/20 text-red-400' : 'bg-red-500/20 text-red-400'
}`}> }`}>
{cert.status} {certStatus}
</span> </span>
</p> </p>
{cert.providerId && ( {certProviderId && (
<p className="text-slate-400 text-sm"> <p className="text-slate-400 text-sm">
<span className="font-semibold text-slate-300">Provider:</span>{' '} <span className="font-semibold text-slate-300">Provider:</span>{' '}
{cert.providerId} {certProviderId}
</p> </p>
)} )}
</div> </div>
</div> </div>
<button <button
onClick={() => handleRefreshCertificate(cert)} onClick={() => handleRefreshCertificate(cert)}
disabled={refreshingCertificate === cert.id} disabled={refreshingCertificate === certId || !selectedFqdn}
className="ml-4 px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors" className="ml-4 px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white text-sm rounded-lg transition-colors"
title="Zertifikat von CA abrufen" title="Zertifikat von CA abrufen"
> >
{refreshingCertificate === cert.id ? 'Aktualisiere...' : 'Aktualisieren'} {refreshingCertificate === certId ? 'Aktualisiere...' : 'Aktualisieren'}
</button> </button>
</div> </div>
{cert.certificatePEM && ( {certPEM && (
<div className="mt-3"> <div className="mt-3">
<h5 className="text-sm font-semibold text-slate-300 mb-2">Zertifikat (PEM):</h5> <h5 className="text-sm font-semibold text-slate-300 mb-2">Zertifikat (PEM):</h5>
<pre className="text-xs text-slate-200 bg-slate-900/50 p-3 rounded overflow-auto max-h-60 font-mono"> <pre className="text-xs text-slate-200 bg-slate-900/50 p-3 rounded overflow-auto max-h-60 font-mono">
{cert.certificatePEM} {certPEM}
</pre> </pre>
</div> </div>
)} )}
</div> </div>
))} )
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../contexts/PermissionsContext'
const Spaces = () => { const Spaces = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { authFetch } = useAuth() const { authFetch } = useAuth()
const { canCreateSpace, canDeleteSpace, refreshPermissions } = usePermissions()
const [spaces, setSpaces] = useState([]) const [spaces, setSpaces] = useState([])
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -72,6 +74,8 @@ const Spaces = () => {
setSpaces([...spaces, newSpace]) setSpaces([...spaces, newSpace])
setFormData({ name: '', description: '' }) setFormData({ name: '', description: '' })
setShowForm(false) setShowForm(false)
// Aktualisiere Berechtigungen nach dem Erstellen eines Spaces
refreshPermissions()
} else { } else {
const errorData = await response.json() const errorData = await response.json()
setError(errorData.error || 'Fehler beim Erstellen des Space') setError(errorData.error || 'Fehler beim Erstellen des Space')
@@ -140,6 +144,8 @@ const Spaces = () => {
setConfirmChecked(false) setConfirmChecked(false)
setDeleteFqdnsChecked(false) setDeleteFqdnsChecked(false)
setFqdnCount(0) setFqdnCount(0)
// Aktualisiere Berechtigungen nach dem Löschen eines Spaces
refreshPermissions()
} else { } else {
const errorText = await response.text() const errorText = await response.text()
let errorMessage = 'Fehler beim Löschen des Space' let errorMessage = 'Fehler beim Löschen des Space'
@@ -188,7 +194,13 @@ const Spaces = () => {
</div> </div>
<button <button
onClick={() => setShowForm(!showForm)} 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'} {showForm ? 'Abbrechen' : '+ Neuer Space'}
</button> </button>
@@ -340,11 +352,18 @@ const Spaces = () => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (canDeleteSpace(space.id)) {
handleDelete(space) handleDelete(space)
}
}} }}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/20 rounded-lg transition-colors" disabled={!canDeleteSpace(space.id)}
title="Space löschen" className={`p-2 rounded-lg transition-colors ${
aria-label="Space löschen" 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 <svg
className="w-5 h-5" className="w-5 h-5"

View File

@@ -1,23 +1,37 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { usePermissions } from '../contexts/PermissionsContext'
const Users = () => { const Users = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const { refreshPermissions } = usePermissions()
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [groups, setGroups] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editingUser, setEditingUser] = useState(null) const [editingUser, setEditingUser] = useState(null)
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({ const [formData, setFormData] = useState({
username: '', username: '',
email: '', email: '',
oldPassword: '', oldPassword: '',
password: '', password: '',
confirmPassword: '' confirmPassword: '',
isAdmin: false,
enabled: true,
groupIds: []
}) })
const [showAdminWarning, setShowAdminWarning] = useState(false)
useEffect(() => { useEffect(() => {
fetchUsers() fetchUsers()
fetchGroups()
}, []) }, [])
const fetchUsers = async () => { const fetchUsers = async () => {
@@ -36,6 +50,18 @@ const Users = () => {
} }
} }
const fetchGroups = async () => {
try {
const response = await authFetch('/api/permission-groups')
if (response.ok) {
const data = await response.json()
setGroups(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Error fetching permission groups:', err)
}
}
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -63,17 +89,25 @@ const Users = () => {
const body = editingUser const body = editingUser
? { ? {
...(formData.username && { username: formData.username }), // Username/Email nur setzen wenn nicht der spezielle Admin-User mit UID 'admin'
...(formData.email && { email: formData.email }), ...(formData.username && editingUser.id !== 'admin' && { username: formData.username }),
...(formData.email && editingUser.id !== 'admin' && { email: formData.email }),
...(formData.password && { ...(formData.password && {
password: formData.password, password: formData.password,
oldPassword: formData.oldPassword 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, username: formData.username,
email: formData.email, email: formData.email,
password: formData.password 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 || []
} }
const response = await authFetch(url, { const response = await authFetch(url, {
@@ -86,9 +120,12 @@ const Users = () => {
if (response.ok) { if (response.ok) {
await fetchUsers() await fetchUsers()
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' }) setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
setShowForm(false) setShowForm(false)
setEditingUser(null) setEditingUser(null)
setShowAdminWarning(false)
// Aktualisiere Berechtigungen nach Änderung an Benutzern (Gruppen-Zuweisungen könnten sich geändert haben)
refreshPermissions()
} else { } else {
const errorData = await response.json() const errorData = await response.json()
setError(errorData.error || 'Fehler beim Speichern des Benutzers') setError(errorData.error || 'Fehler beim Speichern des Benutzers')
@@ -108,33 +145,122 @@ const Users = () => {
email: user.email, email: user.email,
oldPassword: '', oldPassword: '',
password: '', password: '',
confirmPassword: '' 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) setShowForm(true)
} }
const handleDelete = async (userId) => { const handleDelete = (user) => {
if (!window.confirm('Möchten Sie diesen Benutzer wirklich löschen?')) { setUserToDelete(user)
setShowDeleteModal(true)
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 return
} }
try { try {
const response = await authFetch(`/api/users/${userId}`, { const response = await authFetch(`/api/users/${userToDelete.id}`, {
method: 'DELETE', method: 'DELETE',
}) })
if (response.ok) { if (response.ok) {
await fetchUsers() await fetchUsers()
setShowDeleteModal(false)
setUserToDelete(null)
setConfirmChecked(false)
// Aktualisiere Berechtigungen nach Löschen eines Benutzers
refreshPermissions()
} else { } else {
const errorData = await response.json() 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) { } catch (err) {
alert('Fehler beim Löschen des Benutzers')
console.error('Error deleting user:', err) console.error('Error deleting user:', err)
setError('Fehler beim Löschen des Benutzers')
} }
} }
const cancelDelete = () => {
setShowDeleteModal(false)
setUserToDelete(null)
setConfirmChecked(false)
}
const handleChange = (e) => { const handleChange = (e) => {
setFormData({ setFormData({
...formData, ...formData,
@@ -142,6 +268,44 @@ const Users = () => {
}) })
} }
const handleGroupToggle = (groupId) => {
setFormData(prev => {
const groupIds = prev.groupIds || []
if (groupIds.includes(groupId)) {
return { ...prev, groupIds: groupIds.filter(id => id !== groupId) }
} else {
return { ...prev, groupIds: [...groupIds, groupId] }
}
})
}
const handleAdminToggle = (e) => {
const isAdmin = e.target.checked
if (isAdmin && !showAdminWarning) {
setShowAdminWarning(true)
}
setFormData(prev => ({
...prev,
isAdmin,
// Wenn Admin aktiviert wird, entferne alle Gruppen und stelle sicher dass enabled=true
groupIds: isAdmin ? [] : prev.groupIds,
enabled: isAdmin ? true : (prev.enabled !== undefined ? prev.enabled : true) // Admin muss immer enabled sein
}))
}
const getPermissionLabel = (permission) => {
switch (permission) {
case 'READ':
return 'Lesen'
case 'READ_WRITE':
return 'Lesen/Schreiben'
case 'FULL_ACCESS':
return 'Vollzugriff'
default:
return permission
}
}
return ( return (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900"> <div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
@@ -156,7 +320,8 @@ const Users = () => {
onClick={() => { onClick={() => {
setShowForm(!showForm) setShowForm(!showForm)
setEditingUser(null) setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' }) setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
setShowAdminWarning(false)
}} }}
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" 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"
> >
@@ -182,9 +347,15 @@ const Users = () => {
value={formData.username} value={formData.username}
onChange={handleChange} onChange={handleChange}
required={!editingUser} 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" 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>
<div> <div>
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2"> <label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
@@ -197,9 +368,15 @@ const Users = () => {
value={formData.email} value={formData.email}
onChange={handleChange} onChange={handleChange}
required={!editingUser} 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" 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> </div>
{editingUser && ( {editingUser && (
<div> <div>
@@ -284,6 +461,75 @@ const Users = () => {
<p className="mt-1 text-xs text-green-400"> Passwörter stimmen überein</p> <p className="mt-1 text-xs text-green-400"> Passwörter stimmen überein</p>
)} )}
</div> </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.username === '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 ${
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 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">
<div className="flex items-center gap-2">
<span className="text-slate-300 font-medium">{group.name}</span>
<span className="px-2 py-0.5 bg-blue-600/20 text-blue-300 rounded text-xs">
{getPermissionLabel(group.permission)}
</span>
</div>
{group.description && (
<p className="text-xs text-slate-400 mt-1">{group.description}</p>
)}
</div>
</label>
))}
</div>
)}
</div>
</div>
{error && ( {error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm"> <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
{error} {error}
@@ -302,7 +548,8 @@ const Users = () => {
onClick={() => { onClick={() => {
setShowForm(false) setShowForm(false)
setEditingUser(null) setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' }) setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
setShowAdminWarning(false)
setError('') setError('')
}} }}
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200" className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
@@ -343,10 +590,37 @@ const Users = () => {
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<h3 className="text-xl font-semibold text-white mb-2"> <div className="flex items-center gap-2 mb-2">
<h3 className="text-xl font-semibold text-white">
{user.username} {user.username}
</h3> </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> <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">
{user.groupIds.map(groupId => {
const group = groups.find(g => g.id === groupId)
return group ? (
<span key={groupId} className="px-2 py-1 bg-blue-600/20 text-blue-300 rounded text-xs">
{group.name} ({getPermissionLabel(group.permission)})
</span>
) : null
})}
</div>
</div>
)}
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Erstellt: {user.createdAt ? new Date(user.createdAt).toLocaleString('de-DE') : 'Unbekannt'} Erstellt: {user.createdAt ? new Date(user.createdAt).toLocaleString('de-DE') : 'Unbekannt'}
</p> </p>
@@ -363,12 +637,26 @@ const Users = () => {
> >
Bearbeiten Bearbeiten
</button> </button>
{user.id === 'admin' ? (
<button <button
onClick={() => handleDelete(user.id)} 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" className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
> >
Löschen Löschen
</button> </button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -376,6 +664,222 @@ const Users = () => {
</div> </div>
)} )}
</div> </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={() => 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={() => 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">
<div className="bg-slate-800 rounded-xl shadow-2xl border border-slate-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">
Benutzer löschen
</h3>
</div>
<div className="mb-6">
<p className="text-slate-300 mb-4">
Möchten Sie den Benutzer <span className="font-semibold text-white">{userToDelete.username}</span> wirklich löschen?
</p>
<p className="text-sm text-red-400 mb-4">
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
<label className="flex items-start cursor-pointer group">
<input
type="checkbox"
checked={confirmChecked}
onChange={(e) => setConfirmChecked(e.target.checked)}
className="mt-1 w-5 h-5 text-red-600 bg-slate-700 border-slate-600 rounded focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-slate-800 cursor-pointer"
/>
<span className="ml-3 text-sm text-slate-300 group-hover:text-white transition-colors">
Ich bestätige, dass ich diesen Benutzer unwiderruflich löschen möchte
</span>
</label>
</div>
<div className="flex gap-3">
<button
onClick={confirmDelete}
disabled={!confirmChecked}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors duration-200"
>
Löschen
</button>
<button
onClick={cancelDelete}
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>
)}
{/* 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>
</div> </div>
) )