Compare commits

...

13 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
19 changed files with 2906 additions and 249 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'
@@ -10,6 +12,7 @@ import Impressum from './pages/Impressum'
import Profile from './pages/Profile' import Profile from './pages/Profile'
import Users from './pages/Users' import Users from './pages/Users'
import Permissions from './pages/Permissions' import Permissions from './pages/Permissions'
import Providers from './pages/Providers'
import Login from './pages/Login' import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs' import AuditLogs from './pages/AuditLogs'
@@ -34,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()
@@ -67,13 +149,14 @@ const AppContent = () => {
<Routes> <Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} /> <Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} /> <Route path="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} /> <Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} /> <Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} /> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route path="/settings/users" element={<ProtectedRoute><Users /></ProtectedRoute>} /> <Route path="/settings/users" element={<AdminRoute><Users /></AdminRoute>} />
<Route path="/settings/permissions" element={<ProtectedRoute><Permissions /></ProtectedRoute>} /> <Route path="/settings/permissions" element={<AdminRoute><Permissions /></AdminRoute>} />
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} /> <Route path="/settings/providers" element={<AdminRoute><Providers /></AdminRoute>} />
<Route path="/audit-logs" element={<GroupRequiredRoute><AuditLogs /></GroupRequiredRoute>} />
</Routes> </Routes>
</div> </div>
<Footer /> <Footer />
@@ -87,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 = {
@@ -23,6 +29,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
subItems: [ subItems: [
{ path: '/settings/users', label: 'User', icon: '👥' }, { path: '/settings/users', label: 'User', icon: '👥' },
{ path: '/settings/permissions', label: 'Berechtigungen', icon: '🔐' }, { path: '/settings/permissions', label: 'Berechtigungen', icon: '🔐' },
{ path: '/settings/providers', label: 'SSL Provider', icon: '🔒' },
] ]
} }
@@ -128,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)}
@@ -184,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

@@ -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 '../contexts/PermissionsContext'
const Permissions = () => { const Permissions = () => {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const { refreshPermissions } = usePermissions()
const [groups, setGroups] = useState([]) const [groups, setGroups] = useState([])
const [spaces, setSpaces] = useState([]) const [spaces, setSpaces] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -112,6 +114,8 @@ const Permissions = () => {
setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] }) setFormData({ name: '', description: '', permission: 'READ', spaceIds: [] })
setShowForm(false) setShowForm(false)
setEditingGroup(null) setEditingGroup(null)
// Aktualisiere Berechtigungen nach Änderung an Berechtigungsgruppen
refreshPermissions()
} else { } else {
const errorData = await response.json() const errorData = await response.json()
setError(errorData.error || 'Fehler beim Speichern der Berechtigungsgruppe') setError(errorData.error || 'Fehler beim Speichern der Berechtigungsgruppe')
@@ -156,6 +160,8 @@ const Permissions = () => {
setShowDeleteModal(false) setShowDeleteModal(false)
setGroupToDelete(null) setGroupToDelete(null)
setConfirmChecked(false) setConfirmChecked(false)
// Aktualisiere Berechtigungen nach Löschen einer Berechtigungsgruppe
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 der Berechtigungsgruppe') alert(errorData.error || 'Fehler beim Löschen der Berechtigungsgruppe')

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,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 '../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 [groups, setGroups] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -12,14 +14,20 @@ const Users = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false)
const [userToDelete, setUserToDelete] = useState(null) const [userToDelete, setUserToDelete] = useState(null)
const [confirmChecked, setConfirmChecked] = useState(false) 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: [] groupIds: []
}) })
const [showAdminWarning, setShowAdminWarning] = useState(false)
useEffect(() => { useEffect(() => {
fetchUsers() fetchUsers()
@@ -81,18 +89,24 @@ 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 }) ...(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 || [] groupIds: formData.groupIds || []
} }
@@ -106,9 +120,12 @@ const Users = () => {
if (response.ok) { if (response.ok) {
await fetchUsers() await fetchUsers()
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] }) 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')
@@ -129,6 +146,8 @@ const Users = () => {
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 || [] groupIds: user.groupIds || []
}) })
setShowForm(true) setShowForm(true)
@@ -140,6 +159,67 @@ const Users = () => {
setConfirmChecked(false) 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 () => { const confirmDelete = async () => {
if (!confirmChecked || !userToDelete) { if (!confirmChecked || !userToDelete) {
return return
@@ -155,13 +235,23 @@ const Users = () => {
setShowDeleteModal(false) setShowDeleteModal(false)
setUserToDelete(null) setUserToDelete(null)
setConfirmChecked(false) setConfirmChecked(false)
// Aktualisiere Berechtigungen nach Löschen eines Benutzers
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 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) {
console.error('Error deleting user:', err) console.error('Error deleting user:', err)
alert('Fehler beim Löschen des Benutzers') setError('Fehler beim Löschen des Benutzers')
} }
} }
@@ -189,6 +279,20 @@ const Users = () => {
}) })
} }
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) => { const getPermissionLabel = (permission) => {
switch (permission) { switch (permission) {
case 'READ': case 'READ':
@@ -216,7 +320,8 @@ const Users = () => {
onClick={() => { onClick={() => {
setShowForm(!showForm) setShowForm(!showForm)
setEditingUser(null) setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] }) 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"
> >
@@ -242,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">
@@ -257,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>
@@ -344,21 +461,56 @@ 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> <div>
<label className="block text-sm font-medium text-slate-200 mb-2"> <label className="block text-sm font-medium text-slate-200 mb-2">
Berechtigungsgruppen Berechtigungsgruppen
{(formData.isAdmin || (editingUser && editingUser.id === 'admin')) && <span className="text-xs text-slate-400 ml-2">(nicht verfügbar für Administratoren)</span>}
</label> </label>
<div className="bg-slate-700/50 border border-slate-600 rounded-lg p-4 max-h-60 overflow-y-auto"> <div className={`bg-slate-700/50 border border-slate-600 rounded-lg p-4 max-h-60 overflow-y-auto ${
formData.isAdmin || (editingUser && editingUser.id === 'admin') ? 'opacity-50 pointer-events-none' : ''
}`}>
{groups.length === 0 ? ( {groups.length === 0 ? (
<p className="text-slate-400 text-sm">Keine Berechtigungsgruppen vorhanden</p> <p className="text-slate-400 text-sm">Keine Berechtigungsgruppen vorhanden</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{groups.map(group => ( {groups.map(group => (
<label key={group.id} className="flex items-start cursor-pointer hover:bg-slate-600/50 p-2 rounded"> <label key={group.id} className={`flex items-start p-2 rounded ${
formData.isAdmin || (editingUser && editingUser.id === 'admin') ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-slate-600/50'
}`}>
<input <input
type="checkbox" type="checkbox"
checked={formData.groupIds?.includes(group.id) || false} checked={formData.groupIds?.includes(group.id) || false}
onChange={() => handleGroupToggle(group.id)} 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" 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="ml-3 flex-1">
@@ -396,7 +548,8 @@ const Users = () => {
onClick={() => { onClick={() => {
setShowForm(false) setShowForm(false)
setEditingUser(null) setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', groupIds: [] }) 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"
@@ -437,11 +590,23 @@ 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.groupIds && user.groupIds.length > 0 && ( {!user.isAdmin && user.groupIds && user.groupIds.length > 0 && (
<div className="mb-2"> <div className="mb-2">
<p className="text-sm text-slate-300 mb-1">Berechtigungsgruppen:</p> <p className="text-sm text-slate-300 mb-1">Berechtigungsgruppen:</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -472,12 +637,26 @@ const Users = () => {
> >
Bearbeiten Bearbeiten
</button> </button>
{user.id === 'admin' ? (
<button
onClick={() => handleToggleEnabled(user)}
className={`px-4 py-2 text-white text-sm rounded-lg transition-colors ${
user.enabled
? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-green-600 hover:bg-green-700'
}`}
title={user.enabled ? "Admin-User deaktivieren" : "Admin-User aktivieren"}
>
{user.enabled ? 'Deaktivieren' : 'Aktivieren'}
</button>
) : (
<button <button
onClick={() => handleDelete(user)} 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>
@@ -486,6 +665,66 @@ const Users = () => {
)} )}
</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 */} {/* Delete Confirmation Modal */}
{showDeleteModal && userToDelete && ( {showDeleteModal && userToDelete && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
@@ -548,6 +787,99 @@ const Users = () => {
</div> </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>
) )