implemented LE and ACME and fixed some bugs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
75
frontend/src/components/Toast.jsx
Normal file
75
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const Toast = ({ message, type = 'error', onClose, duration = 4000 }) => {
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose()
|
||||
}, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [duration, onClose])
|
||||
|
||||
const typeStyles = {
|
||||
error: 'bg-red-500/90 border-red-400/50 text-white',
|
||||
warning: 'bg-amber-500/90 border-amber-400/50 text-white',
|
||||
info: 'bg-blue-500/90 border-blue-400/50 text-white',
|
||||
success: 'bg-emerald-500/90 border-emerald-400/50 text-white'
|
||||
}
|
||||
|
||||
const iconStyles = {
|
||||
error: 'text-red-200',
|
||||
warning: 'text-amber-200',
|
||||
info: 'text-blue-200',
|
||||
success: 'text-emerald-200'
|
||||
}
|
||||
|
||||
const icons = {
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-3 px-4 py-3 rounded-lg border-2 shadow-2xl backdrop-blur-sm min-w-[300px] max-w-[500px] animate-slide-in-right transition-all duration-300 ${typeStyles[type]}`}
|
||||
role="alert"
|
||||
>
|
||||
<div className={`flex-shrink-0 ${iconStyles[type]}`}>
|
||||
{icons[type]}
|
||||
</div>
|
||||
<div className="flex-1 text-sm font-medium">
|
||||
{message}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 text-white/80 hover:text-white transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
|
||||
@@ -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({
|
||||
|
||||
98
frontend/src/contexts/ToastContext.jsx
Normal file
98
frontend/src/contexts/ToastContext.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
|
||||
import Toast from '../components/Toast'
|
||||
|
||||
const ToastContext = createContext()
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const queueRef = useRef([])
|
||||
const isProcessingRef = useRef(false)
|
||||
const timeoutRef = useRef(null)
|
||||
|
||||
// Sequenzielle Verarbeitung der Toast-Queue
|
||||
const processQueue = useCallback(() => {
|
||||
// Wenn bereits verarbeitet wird oder Queue leer, nichts tun
|
||||
if (isProcessingRef.current || queueRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingRef.current = true
|
||||
const nextToast = queueRef.current.shift()
|
||||
|
||||
if (nextToast) {
|
||||
// Füge Toast zum State hinzu
|
||||
setToasts((prev) => [...prev, nextToast])
|
||||
|
||||
// Warte 200ms bevor der nächste Toast verarbeitet wird
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
isProcessingRef.current = false
|
||||
// Verarbeite nächsten Toast in der Queue
|
||||
processQueue()
|
||||
}, 200)
|
||||
} else {
|
||||
isProcessingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Starte Verarbeitung wenn Queue nicht leer ist
|
||||
useEffect(() => {
|
||||
if (queueRef.current.length > 0 && !isProcessingRef.current) {
|
||||
processQueue()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [processQueue])
|
||||
|
||||
const showToast = useCallback((message, type = 'error', duration = 4000) => {
|
||||
const id = Date.now() + Math.random()
|
||||
const newToast = { id, message, type, duration }
|
||||
|
||||
// Füge zur Queue hinzu
|
||||
queueRef.current.push(newToast)
|
||||
|
||||
// Starte Verarbeitung wenn nicht bereits aktiv
|
||||
if (!isProcessingRef.current) {
|
||||
processQueue()
|
||||
}
|
||||
|
||||
return id
|
||||
}, [processQueue])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, removeToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 pointer-events-none flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="pointer-events-auto"
|
||||
>
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
148
frontend/src/pages/RenewalQueue.jsx
Normal file
148
frontend/src/pages/RenewalQueue.jsx
Normal 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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user