implemented LE and ACME and fixed some bugs

This commit is contained in:
2025-11-27 04:20:09 +01:00
parent ec1e0da9d5
commit 145dfd3d7c
36 changed files with 10583 additions and 1107 deletions

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { PermissionsProvider } from './contexts/PermissionsContext'
import { ToastProvider, useToast } from './contexts/ToastContext'
import { usePermissions } from './hooks/usePermissions'
import Sidebar from './components/Sidebar'
import Footer from './components/Footer'
@@ -15,6 +16,7 @@ import Permissions from './pages/Permissions'
import Providers from './pages/Providers'
import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs'
import RenewalQueue from './pages/RenewalQueue'
// Protected Route Component
const ProtectedRoute = ({ children }) => {
@@ -75,10 +77,12 @@ const AdminRoute = ({ children }) => {
}
// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
const GroupRequiredRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
const GroupRequiredRoute = ({ children, allowHomePage = false }) => {
const { isAuthenticated, loading, user } = useAuth()
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
const location = useLocation()
// Warte, bis sowohl Auth als auch Permissions geladen sind
if (loading || permissionsLoading) {
return (
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
@@ -97,19 +101,50 @@ const GroupRequiredRoute = ({ children }) => {
return <Navigate to="/login" replace />
}
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Verwende isAdmin aus Permissions, oder Fallback auf user.isAdmin aus AuthContext
// WICHTIG: Nur prüfen, wenn Permissions vollständig geladen sind (permissionsLoading === false)
const effectiveIsAdmin = isAdmin || (user?.isAdmin === true)
if (!hasGroups) {
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = effectiveIsAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Debug-Logging (kann später entfernt werden)
if (process.env.NODE_ENV === 'development') {
console.log('GroupRequiredRoute Debug:', {
isAdmin,
userIsAdmin: user?.isAdmin,
effectiveIsAdmin,
hasFullAccess,
accessibleSpacesLength: accessibleSpaces?.length || 0,
hasGroups,
pathname: location.pathname,
allowHomePage,
permissionsLoading,
authLoading: loading
})
}
// Zeige Warnung, wenn keine Berechtigung vorhanden ist, aber leite nicht weiter
// Dies verhindert, dass Benutzer beim Refresh zur Home-Seite weitergeleitet werden
if (!hasGroups && !permissionsLoading) {
// Erlaube Zugriff auf alle Seiten, aber zeige Warnung
return (
<Navigate
to="/"
replace
state={{
message: "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.",
type: "warning"
}}
/>
<div>
<div className="mb-6 p-4 rounded-lg border bg-yellow-500/20 border-yellow-500/50">
<div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0 text-yellow-400"></div>
<div className="flex-1">
<p className="font-semibold mb-1 text-yellow-300">
Keine Berechtigungsgruppe
</p>
<p className="text-sm text-yellow-200">
Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.
</p>
</div>
</div>
</div>
{children}
</div>
)
}
@@ -139,6 +174,153 @@ const PublicRoute = ({ children }) => {
const AppContent = () => {
const [sidebarOpen, setSidebarOpen] = useState(true)
const { showToast } = useToast()
// Globale Validierungsmeldungen für alle required-Felder
useEffect(() => {
const setupCustomValidation = () => {
// Funktion zum Setzen benutzerdefinierter Validierungsmeldungen
const setCustomValidityMessages = () => {
const requiredFields = document.querySelectorAll('input[required], select[required], textarea[required]')
requiredFields.forEach((field) => {
// Überspringe Felder, die bereits behandelt wurden
if (field.dataset.customValidation === 'true') {
return
}
// Markiere Feld als behandelt
field.dataset.customValidation = 'true'
// Setze benutzerdefinierte Meldung basierend auf Feldtyp und Label
const label = document.querySelector(`label[for="${field.id}"]`) ||
field.closest('label') ||
field.previousElementSibling
let customMessage = 'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
// Spezifische Meldungen basierend auf Feldtyp oder Label
if (field.type === 'email') {
customMessage = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'
} else if (field.type === 'password') {
customMessage = 'Bitte geben Sie ein Passwort ein.'
} else if (field.tagName === 'SELECT') {
customMessage = 'Bitte wählen Sie eine Option aus.'
} else if (label) {
const labelText = label.textContent || ''
if (labelText.toLowerCase().includes('name') || labelText.toLowerCase().includes('benutzername')) {
customMessage = 'Bitte geben Sie einen Namen ein.'
} else if (labelText.toLowerCase().includes('fqdn') || labelText.toLowerCase().includes('domain')) {
customMessage = 'Bitte geben Sie einen FQDN ein.'
} else if (labelText.toLowerCase().includes('provider')) {
customMessage = 'Bitte wählen Sie einen Provider aus.'
} else if (labelText.toLowerCase().includes('beschreibung')) {
customMessage = 'Bitte geben Sie eine Beschreibung ein.'
}
}
// Speichere die benutzerdefinierte Meldung am Feld
field.dataset.customMessage = customMessage
// Setze benutzerdefinierte Validierungsmeldung
const handleInvalid = (e) => {
e.preventDefault()
field.setCustomValidity('') // Entferne Standard-Meldung
// Fallback: Zeige Toast auch bei einzelnen Feld-Validierungen
// (wird normalerweise über handleFormSubmit gesteuert)
// Nur wenn das Feld direkt validiert wird (z.B. bei onBlur)
if (!field.form || !field.form.dataset.submitAttempted) {
showToast(customMessage, 'error', 5000)
}
}
// Entferne benutzerdefinierte Meldung bei Eingabe/Änderung
const handleInput = () => {
field.setCustomValidity('')
}
field.addEventListener('invalid', handleInvalid)
field.addEventListener('input', handleInput)
field.addEventListener('change', handleInput)
})
}
// Initiale Einrichtung
setCustomValidityMessages()
// Beobachte DOM-Änderungen für dynamisch hinzugefügte Formulare
const observer = new MutationObserver(() => {
// Kleine Verzögerung, damit React die DOM-Änderungen abgeschlossen hat
setTimeout(() => {
setCustomValidityMessages()
}, 100)
})
observer.observe(document.body, {
childList: true,
subtree: true
})
// Einrichtung bei Form-Submit
const handleFormSubmit = (e) => {
const form = e.target
if (form.tagName === 'FORM') {
// Markiere Formular als Submit-Versuch
form.dataset.submitAttempted = 'true'
// Prüfe zuerst, ob das Formular gültig ist
if (!form.checkValidity()) {
e.preventDefault()
e.stopPropagation()
// Sammle alle ungültigen Felder
const invalidFields = Array.from(form.querySelectorAll('input[required], select[required], textarea[required]')).filter((field) => {
// Prüfe explizit, ob das Feld ungültig ist
if (field.type === 'checkbox' || field.type === 'radio') {
return !field.checked && field.required
}
if (field.tagName === 'SELECT') {
return !field.value && field.required
}
return !field.value.trim() && field.required
})
if (invalidFields.length > 0) {
// Fokussiere das erste ungültige Feld
const firstInvalid = invalidFields[0]
firstInvalid.focus()
// Sammle alle Validierungsmeldungen
const messages = invalidFields.map((field) => {
const customMessage = field.dataset.customMessage ||
'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
return customMessage
})
// Zeige alle Toasts - die Queue im ToastContext sorgt für sequenzielle Anzeige
messages.forEach((message) => {
showToast(message, 'error', 5000)
})
}
} else {
// Formular ist gültig, entferne Markierung
delete form.dataset.submitAttempted
}
}
}
document.addEventListener('submit', handleFormSubmit, true)
return () => {
observer.disconnect()
document.removeEventListener('submit', handleFormSubmit, true)
}
}
const cleanup = setupCustomValidation()
return cleanup
}, [showToast])
return (
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
@@ -148,7 +330,8 @@ const AppContent = () => {
<div className="flex-1">
<Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/" element={<GroupRequiredRoute allowHomePage={true}><Home /></GroupRequiredRoute>} />
<Route path="/queue" element={<GroupRequiredRoute><RenewalQueue /></GroupRequiredRoute>} />
<Route path="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
@@ -171,7 +354,9 @@ function App() {
<Router>
<AuthProvider>
<PermissionsProvider>
<AppContent />
<ToastProvider>
<AppContent />
</ToastProvider>
</PermissionsProvider>
</AuthProvider>
</Router>

View File

@@ -13,14 +13,24 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
// Prüfe ob User Berechtigungsgruppen hat
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Menüpunkte - Home ist immer sichtbar, andere nur mit Gruppen
// Menüpunkte - andere nur mit Gruppen
const menuItems = [
{ path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
{ path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
{ path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
{ path: '/impressum', label: 'Impressum', icon: '', requiresGroups: true },
].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
// Home mit Unterpunkten
const homeMenu = {
label: 'Home',
icon: '🏠',
path: '/',
alwaysVisible: true,
subItems: [
{ path: '/queue', label: 'Queue', icon: '⏰', requiresGroups: true },
].filter(item => !item.requiresGroups || hasGroups)
}
// Settings mit Unterpunkten
const settingsMenu = {
label: 'Settings',
@@ -53,7 +63,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
return expandedMenus[menuPath] || false
}
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
// Automatisch Menüs expandieren, wenn auf einer entsprechenden Seite
useEffect(() => {
if (location.pathname.startsWith('/settings')) {
setExpandedMenus(prev => ({
@@ -61,6 +71,12 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
'/settings': true
}))
}
if (location.pathname === '/queue' || location.pathname === '/') {
setExpandedMenus(prev => ({
...prev,
'/': true
}))
}
}, [location.pathname])
return (
@@ -112,6 +128,82 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</div>
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
<ul className="space-y-2 flex-1">
{/* Home Menu mit Unterpunkten */}
<li>
<div className={`flex items-center rounded-lg transition-all duration-200 ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'bg-slate-700 shadow-md'
: ''
}`}>
<Link
to={homeMenu.path}
className={`flex-1 flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'text-white font-semibold'
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
}`}
title={!isOpen ? homeMenu.label : ''}
>
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
{homeMenu.icon}
</span>
{isOpen && (
<span className="whitespace-nowrap overflow-hidden">
{homeMenu.label}
</span>
)}
</Link>
{isOpen && homeMenu.subItems && homeMenu.subItems.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleMenu(homeMenu.path)
}}
className={`p-2 rounded-lg transition-colors ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'text-white hover:bg-slate-600'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
}`}
title="Menü erweitern"
>
<svg
className={`w-4 h-4 transition-transform duration-200 ${
isMenuExpanded(homeMenu.path) ? 'rotate-90' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
{isOpen && isMenuExpanded(homeMenu.path) && homeMenu.subItems && homeMenu.subItems.length > 0 && (
<ul className="ml-4 mt-1 space-y-1">
{homeMenu.subItems.map((subItem) => (
<li key={subItem.path}>
<Link
to={subItem.path}
className={`flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${
isActive(subItem.path)
? 'bg-slate-600 text-white font-semibold'
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
}`}
>
<span className="text-lg flex-shrink-0 mr-2">
{subItem.icon}
</span>
<span className="whitespace-nowrap overflow-hidden">
{subItem.label}
</span>
</Link>
</li>
))}
</ul>
)}
</li>
{menuItems.map((item) => (
<li key={item.path}>
<Link

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react'
const Toast = ({ message, type = 'error', onClose, duration = 4000 }) => {
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
onClose()
}, duration)
return () => clearTimeout(timer)
}
}, [duration, onClose])
const typeStyles = {
error: 'bg-red-500/90 border-red-400/50 text-white',
warning: 'bg-amber-500/90 border-amber-400/50 text-white',
info: 'bg-blue-500/90 border-blue-400/50 text-white',
success: 'bg-emerald-500/90 border-emerald-400/50 text-white'
}
const iconStyles = {
error: 'text-red-200',
warning: 'text-amber-200',
info: 'text-blue-200',
success: 'text-emerald-200'
}
const icons = {
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
return (
<div
className={`flex items-start gap-3 px-4 py-3 rounded-lg border-2 shadow-2xl backdrop-blur-sm min-w-[300px] max-w-[500px] animate-slide-in-right transition-all duration-300 ${typeStyles[type]}`}
role="alert"
>
<div className={`flex-shrink-0 ${iconStyles[type]}`}>
{icons[type]}
</div>
<div className="flex-1 text-sm font-medium">
{message}
</div>
<button
onClick={onClose}
className="flex-shrink-0 text-white/80 hover:text-white transition-colors"
aria-label="Schließen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
export default Toast

View File

@@ -19,6 +19,7 @@ export const PermissionsProvider = ({ children }) => {
canUploadCSR: {},
canSignCSR: {},
})
// Initial loading state: true wenn authentifiziert (weil Permissions noch nicht geladen sind)
const [loading, setLoading] = useState(true)
const intervalRef = useRef(null)
const isMountedRef = useRef(true)
@@ -37,6 +38,14 @@ export const PermissionsProvider = ({ children }) => {
if (response.ok && isMountedRef.current) {
try {
const data = await response.json()
// Debug-Logging (kann später entfernt werden)
if (process.env.NODE_ENV === 'development') {
console.log('Permissions loaded:', {
isAdmin: data.isAdmin,
hasFullAccess: data.hasFullAccess,
accessibleSpacesLength: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces.length : 0
})
}
// Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
setPermissions({
isAdmin: data.isAdmin || false,
@@ -49,18 +58,36 @@ export const PermissionsProvider = ({ children }) => {
canUploadCSR: data.permissions?.canUploadCSR || {},
canSignCSR: data.permissions?.canSignCSR || {},
})
// Setze loading nur auf false, wenn Permissions erfolgreich geladen wurden
if (isInitial && isMountedRef.current) {
setLoading(false)
}
} catch (parseErr) {
console.error('Error parsing permissions response:', parseErr)
// Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
// Setze loading auf false, damit die App nicht hängt
if (isInitial && isMountedRef.current) {
setLoading(false)
}
}
} else if (response.status === 401 && isMountedRef.current) {
// Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
console.log('Unauthorized - permissions will be cleared by auth context')
if (isInitial && isMountedRef.current) {
setLoading(false)
}
} else {
// Bei anderen Fehlern (z.B. 500) loggen, aber loading auf false setzen
const errorText = await response.text().catch(() => 'Unable to read error response')
console.error('Error fetching permissions: HTTP', response.status, errorText)
if (isInitial && isMountedRef.current) {
setLoading(false)
}
}
} catch (err) {
console.error('Error fetching permissions:', err)
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
} finally {
// Setze loading auf false, damit die App nicht hängt
if (isInitial && isMountedRef.current) {
setLoading(false)
}
@@ -70,6 +97,8 @@ export const PermissionsProvider = ({ children }) => {
// Initiales Laden der Permissions
useEffect(() => {
if (isAuthenticated) {
// Setze loading auf true, bevor Permissions geladen werden
setLoading(true)
fetchPermissions(true)
} else {
setPermissions({

View File

@@ -0,0 +1,98 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import Toast from '../components/Toast'
const ToastContext = createContext()
export const useToast = () => {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([])
const queueRef = useRef([])
const isProcessingRef = useRef(false)
const timeoutRef = useRef(null)
// Sequenzielle Verarbeitung der Toast-Queue
const processQueue = useCallback(() => {
// Wenn bereits verarbeitet wird oder Queue leer, nichts tun
if (isProcessingRef.current || queueRef.current.length === 0) {
return
}
isProcessingRef.current = true
const nextToast = queueRef.current.shift()
if (nextToast) {
// Füge Toast zum State hinzu
setToasts((prev) => [...prev, nextToast])
// Warte 200ms bevor der nächste Toast verarbeitet wird
timeoutRef.current = setTimeout(() => {
isProcessingRef.current = false
// Verarbeite nächsten Toast in der Queue
processQueue()
}, 200)
} else {
isProcessingRef.current = false
}
}, [])
// Starte Verarbeitung wenn Queue nicht leer ist
useEffect(() => {
if (queueRef.current.length > 0 && !isProcessingRef.current) {
processQueue()
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [processQueue])
const showToast = useCallback((message, type = 'error', duration = 4000) => {
const id = Date.now() + Math.random()
const newToast = { id, message, type, duration }
// Füge zur Queue hinzu
queueRef.current.push(newToast)
// Starte Verarbeitung wenn nicht bereits aktiv
if (!isProcessingRef.current) {
processQueue()
}
return id
}, [processQueue])
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, [])
return (
<ToastContext.Provider value={{ showToast, removeToast }}>
{children}
<div className="fixed top-4 right-4 z-50 pointer-events-none flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="pointer-events-auto"
>
<Toast
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => removeToast(toast.id)}
/>
</div>
))}
</div>
</ToastContext.Provider>
)
}

View File

@@ -55,3 +55,26 @@ body {
}
}
/* Toast Animation */
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
/* Toast Container - Stapelung mehrerer Toasts */
.toast-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../contexts/AuthContext'
const RenewalQueue = () => {
const { authFetch } = useAuth()
const [queue, setQueue] = useState([])
const [loading, setLoading] = useState(true)
const fetchQueue = async () => {
try {
const response = await authFetch('/api/renewal-queue')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setQueue(data.queue || [])
setLoading(false)
} catch (err) {
console.error('Error fetching renewal queue:', err)
setLoading(false)
}
}
useEffect(() => {
fetchQueue()
const interval = setInterval(fetchQueue, 30000) // Refresh every 30 seconds
return () => clearInterval(interval)
}, [authFetch])
const getStatusColor = (status) => {
switch (status) {
case 'pending':
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
case 'processing':
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
case 'completed':
return 'bg-green-500/20 text-green-400 border-green-500/50'
case 'failed':
return 'bg-red-500/20 text-red-400 border-red-500/50'
default:
return 'bg-slate-500/20 text-slate-400 border-slate-500/50'
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
// Prüfe ob es bereits im DB-Format ist (YYYY-MM-DD HH:MM:SS)
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/)
if (match) {
// Parse als UTC und formatiere
const [, year, month, day, hour, minute] = match
const date = new Date(Date.UTC(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
parseInt(hour, 10),
parseInt(minute, 10)
))
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
})
}
// Fallback: Versuche als ISO-String zu parsen
const date = new Date(dateStr)
if (isNaN(date.getTime())) {
return dateStr // Falls Parsing fehlschlägt, gib Original zurück
}
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch (err) {
console.error('Fehler beim Formatieren des Datums:', err, dateStr)
return dateStr
}
}
return (
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
<div className="max-w-10xl mx-auto">
<h1 className="text-4xl font-bold text-white mb-4">Renewal Queue</h1>
<p className="text-lg text-slate-200 mb-8">
Übersicht über geplante und laufende Zertifikatserneuerungen
</p>
{loading ? (
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
<p className="text-slate-300 mt-4">Lade Queue...</p>
</div>
) : (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 overflow-hidden">
{queue.length === 0 ? (
<div className="p-12 text-center">
<p className="text-slate-400 text-lg">Keine Einträge in der Renewal Queue</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-700/50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">FQDN</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Space</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Geplant für</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Verarbeitet</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Fehler</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{queue.map((item) => (
<tr key={item.id} className="hover:bg-slate-700/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-white font-medium">{item.fqdn || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{item.spaceName || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.scheduledAt)}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(item.status)}`}>
{item.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.processedAt)}</td>
<td className="px-6 py-4 text-sm text-red-400">{item.errorMessage || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
)
}
export default RenewalQueue

File diff suppressed because it is too large Load Diff

View File

@@ -208,66 +208,138 @@ const Spaces = () => {
{/* Create Space Form */}
{showForm && (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
<h2 className="text-2xl font-semibold text-white mb-4">
Neuen Space erstellen
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-200 mb-2">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Geben Sie einen Namen ein"
required
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-200 mb-2">
Beschreibung
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="4"
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Geben Sie eine Beschreibung ein (optional)"
/>
</div>
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
{error}
<div className="bg-gradient-to-br from-slate-800/90 to-slate-900/90 backdrop-blur-sm rounded-xl shadow-2xl border border-emerald-500/30 p-8 mb-6 relative overflow-hidden">
{/* Decorative background elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl -mr-32 -mt-32"></div>
<div className="absolute bottom-0 left-0 w-48 h-48 bg-teal-500/10 rounded-full blur-3xl -ml-24 -mb-24"></div>
<div className="relative z-10">
{/* Header with Icon */}
<div className="flex items-center mb-6">
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center mr-4 shadow-lg shadow-emerald-500/25">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-1">
Neuen Space erstellen
</h2>
<p className="text-sm text-slate-400">
Erstellen Sie einen neuen Arbeitsbereich für Ihre Zertifikate
</p>
</div>
)}
<div className="flex gap-3">
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200"
>
{loading ? 'Wird erstellt...' : 'Space erstellen'}
</button>
<button
type="button"
onClick={() => {
setShowForm(false)
setFormData({ name: '', description: '' })
setError('')
}}
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
>
Abbrechen
</button>
</div>
</form>
{/* Info Banner */}
<div className="mb-6 p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start">
<svg className="w-5 h-5 text-emerald-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-emerald-300">
Spaces sind Arbeitsbereiche, in denen Sie FQDNs und Zertifikate organisieren können. Jeder Space kann mehrere FQDNs enthalten.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Input */}
<div className="space-y-2">
<label htmlFor="name" className="flex items-center text-sm font-semibold text-slate-200">
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Name *
</label>
<div className="relative">
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-3 pl-11 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500"
placeholder="z.B. Produktion, Entwicklung, Test"
required
/>
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
</div>
{/* Description Textarea */}
<div className="space-y-2">
<label htmlFor="description" className="flex items-center text-sm font-semibold text-slate-200">
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
</svg>
Beschreibung
<span className="ml-2 text-xs font-normal text-slate-400">(optional)</span>
</label>
<div className="relative">
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="4"
className="w-full px-4 py-3 pl-11 pt-3 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500 resize-none"
placeholder="Geben Sie eine Beschreibung ein (z.B. Verwendungszweck, Standort, Team, etc.)"
/>
<svg className="absolute left-3 top-3 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
</svg>
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-4 bg-red-500/20 border-2 border-red-500/50 rounded-lg flex items-start">
<svg className="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-red-300">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={loading}
className="flex-1 px-6 py-3 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200 shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 disabled:shadow-none flex items-center justify-center"
>
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wird erstellt...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Space erstellen
</>
)}
</button>
<button
type="button"
onClick={() => {
setShowForm(false)
setFormData({ name: '', description: '' })
setError('')
}}
className="px-6 py-3 bg-slate-700/70 hover:bg-slate-700 border-2 border-slate-600 hover:border-slate-500 text-white font-semibold rounded-lg transition-all duration-200"
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}