push newest version
This commit is contained in:
@@ -1,33 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import Footer from './components/Footer'
|
||||
import Home from './pages/Home'
|
||||
import Spaces from './pages/Spaces'
|
||||
import SpaceDetail from './pages/SpaceDetail'
|
||||
import Impressum from './pages/Impressum'
|
||||
import Profile from './pages/Profile'
|
||||
import Users from './pages/Users'
|
||||
import Login from './pages/Login'
|
||||
import AuditLogs from './pages/AuditLogs'
|
||||
|
||||
function App() {
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Public Route Component (redirects to home if already logged in)
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return !isAuthenticated ? children : <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
const AppContent = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar isOpen={sidebarOpen} setIsOpen={setSidebarOpen} />
|
||||
<main className="flex-1 overflow-y-auto flex flex-col bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/spaces" element={<Spaces />} />
|
||||
<Route path="/spaces/:id" element={<SpaceDetail />} />
|
||||
<Route path="/impressum" element={<Impressum />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar isOpen={sidebarOpen} setIsOpen={setSidebarOpen} />
|
||||
<main className="flex-1 overflow-y-auto flex flex-col bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="flex-1">
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
||||
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/spaces" element={<ProtectedRoute><Spaces /></ProtectedRoute>} />
|
||||
<Route path="/spaces/:id" element={<ProtectedRoute><SpaceDetail /></ProtectedRoute>} />
|
||||
<Route path="/impressum" element={<ProtectedRoute><Impressum /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/settings/users" element={<ProtectedRoute><Users /></ProtectedRoute>} />
|
||||
<Route path="/audit-logs" element={<ProtectedRoute><AuditLogs /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const ProvidersSection = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [providers, setProviders] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showConfigModal, setShowConfigModal] = useState(false)
|
||||
@@ -11,11 +13,11 @@ const ProvidersSection = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders()
|
||||
}, [])
|
||||
}, [authFetch])
|
||||
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/providers')
|
||||
const response = await authFetch('/api/providers')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Definiere feste Reihenfolge der Provider
|
||||
@@ -35,7 +37,7 @@ const ProvidersSection = () => {
|
||||
|
||||
const handleToggleProvider = async (providerId, currentEnabled) => {
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${providerId}/enabled`, {
|
||||
const response = await authFetch(`/api/providers/${providerId}/enabled`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -60,7 +62,7 @@ const ProvidersSection = () => {
|
||||
|
||||
// Lade aktuelle Konfiguration
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${provider.id}`)
|
||||
const response = await authFetch(`/api/providers/${provider.id}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Initialisiere Config-Werte
|
||||
@@ -109,7 +111,7 @@ const ProvidersSection = () => {
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${selectedProvider.id}/test`, {
|
||||
const response = await authFetch(`/api/providers/${selectedProvider.id}/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -134,7 +136,7 @@ const ProvidersSection = () => {
|
||||
if (!selectedProvider) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${selectedProvider.id}/config`, {
|
||||
const response = await authFetch(`/api/providers/${selectedProvider.id}/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const [expandedMenus, setExpandedMenus] = useState({})
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/', label: 'Home', icon: '🏠' },
|
||||
{ path: '/spaces', label: 'Spaces', icon: '📁' },
|
||||
{ path: '/audit-logs', label: 'Audit Log', icon: '📋' },
|
||||
{ path: '/impressum', label: 'Impressum', icon: 'ℹ️' },
|
||||
]
|
||||
|
||||
// Settings mit Unterpunkten
|
||||
const settingsMenu = {
|
||||
label: 'Settings',
|
||||
icon: '⚙️',
|
||||
path: '/settings',
|
||||
subItems: [
|
||||
{ path: '/settings/users', label: 'User', icon: '👥' },
|
||||
]
|
||||
}
|
||||
|
||||
const profileItem = { path: '/profile', label: 'Profil', icon: '👤' }
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === '/') {
|
||||
return location.pathname === '/'
|
||||
@@ -16,6 +34,27 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const toggleMenu = (menuPath) => {
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
[menuPath]: !prev[menuPath]
|
||||
}))
|
||||
}
|
||||
|
||||
const isMenuExpanded = (menuPath) => {
|
||||
return expandedMenus[menuPath] || false
|
||||
}
|
||||
|
||||
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
setExpandedMenus(prev => ({
|
||||
...prev,
|
||||
'/settings': true
|
||||
}))
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay for mobile */}
|
||||
@@ -63,8 +102,8 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="px-2 py-4 overflow-hidden">
|
||||
<ul className="space-y-2">
|
||||
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
|
||||
<ul className="space-y-2 flex-1">
|
||||
{menuItems.map((item) => (
|
||||
<li key={item.path}>
|
||||
<Link
|
||||
@@ -87,7 +126,104 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{/* Settings Menu mit Unterpunkten */}
|
||||
<li>
|
||||
<button
|
||||
onClick={() => isOpen && toggleMenu(settingsMenu.path)}
|
||||
className={`w-full flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
|
||||
isActive(settingsMenu.path)
|
||||
? 'bg-slate-700 text-white font-semibold shadow-md'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
title={!isOpen ? settingsMenu.label : ''}
|
||||
>
|
||||
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
|
||||
{settingsMenu.icon}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<>
|
||||
<span className="whitespace-nowrap overflow-hidden">
|
||||
{settingsMenu.label}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 ml-auto flex-shrink-0 transition-transform duration-200 ${
|
||||
isMenuExpanded(settingsMenu.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>
|
||||
{isOpen && isMenuExpanded(settingsMenu.path) && settingsMenu.subItems && (
|
||||
<ul className="ml-4 mt-1 space-y-1">
|
||||
{settingsMenu.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>
|
||||
</ul>
|
||||
{/* Profil-Eintrag und Logout am unteren Ende */}
|
||||
<div className="mt-auto pt-2 border-t border-slate-700/50 space-y-2">
|
||||
<Link
|
||||
to={profileItem.path}
|
||||
className={`flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
|
||||
isActive(profileItem.path)
|
||||
? 'bg-slate-700 text-white font-semibold shadow-md'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
title={!isOpen ? (user?.username || profileItem.label) : ''}
|
||||
>
|
||||
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
|
||||
{profileItem.icon}
|
||||
</span>
|
||||
{isOpen && (
|
||||
<span className="whitespace-nowrap overflow-hidden">
|
||||
{user?.username || profileItem.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
className={`w-full flex items-center px-3 py-3 rounded-lg transition-all duration-200 text-slate-300 hover:bg-red-600/20 hover:text-red-400 ${
|
||||
isOpen ? '' : 'justify-center'
|
||||
}`}
|
||||
title={!isOpen ? 'Abmelden' : ''}
|
||||
>
|
||||
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : ''}`}>
|
||||
🚪
|
||||
</span>
|
||||
{isOpen && (
|
||||
<span className="whitespace-nowrap overflow-hidden">
|
||||
Abmelden
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
150
frontend/src/contexts/AuthContext.jsx
Normal file
150
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Prüfe beim Start, ob bereits ein Login-Token vorhanden ist
|
||||
const storedAuth = localStorage.getItem('auth')
|
||||
if (storedAuth) {
|
||||
try {
|
||||
const authData = JSON.parse(storedAuth)
|
||||
if (authData.user && authData.credentials) {
|
||||
setUser(authData.user)
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Auth-Daten:', err)
|
||||
localStorage.removeItem('auth')
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
// Erstelle Basic Auth Header
|
||||
const credentials = btoa(`${username}:${password}`)
|
||||
|
||||
console.log('Sende Login-Request:', { username, hasPassword: !!password })
|
||||
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${credentials}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Login-Response Status:', response.status)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('Login erfolgreich:', data)
|
||||
const authData = {
|
||||
user: data.user,
|
||||
credentials: credentials
|
||||
}
|
||||
localStorage.setItem('auth', JSON.stringify(authData))
|
||||
setUser(data.user)
|
||||
setIsAuthenticated(true)
|
||||
return { success: true, user: data.user }
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
console.error('Login-Fehler Response:', errorText)
|
||||
let errorData
|
||||
try {
|
||||
errorData = JSON.parse(errorText)
|
||||
} catch {
|
||||
errorData = { error: errorText || 'Ungültige Anmeldedaten' }
|
||||
}
|
||||
return { success: false, error: errorData.error || 'Ungültige Anmeldedaten' }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login-Fehler (Exception):', err)
|
||||
return { success: false, error: 'Fehler bei der Anmeldung: ' + err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('auth')
|
||||
setUser(null)
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
const getAuthHeader = () => {
|
||||
const storedAuth = localStorage.getItem('auth')
|
||||
if (storedAuth) {
|
||||
try {
|
||||
const authData = JSON.parse(storedAuth)
|
||||
if (authData.credentials) {
|
||||
return `Basic ${authData.credentials}`
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Auth-Daten:', err)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Authenticated fetch wrapper
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const authHeader = getAuthHeader()
|
||||
|
||||
// Wenn Content-Type bereits gesetzt ist (z.B. für multipart/form-data), nicht überschreiben
|
||||
const defaultHeaders = {}
|
||||
// Nur Content-Type setzen, wenn es nicht FormData ist (FormData setzt Content-Type automatisch)
|
||||
if (!(options.body instanceof FormData)) {
|
||||
if (!options.headers || !options.headers['Content-Type']) {
|
||||
defaultHeaders['Content-Type'] = 'application/json'
|
||||
}
|
||||
}
|
||||
if (!options.headers || !options.headers['Accept']) {
|
||||
defaultHeaders['Accept'] = 'application/json'
|
||||
}
|
||||
|
||||
const headers = {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
...(authHeader && { 'Authorization': authHeader }),
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Wenn 401 Unauthorized, logout
|
||||
if (response.status === 401) {
|
||||
logout()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const value = {
|
||||
isAuthenticated,
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
getAuthHeader,
|
||||
authFetch,
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth muss innerhalb eines AuthProvider verwendet werden')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
433
frontend/src/pages/AuditLogs.jsx
Normal file
433
frontend/src/pages/AuditLogs.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [logs, setLogs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filters, setFilters] = useState({
|
||||
action: '',
|
||||
resourceType: '',
|
||||
userId: '',
|
||||
})
|
||||
const [pagination, setPagination] = useState({
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
const [expandedLogs, setExpandedLogs] = useState(new Set())
|
||||
|
||||
const fetchLogs = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError('')
|
||||
|
||||
const params = new URLSearchParams({
|
||||
limit: pagination.limit.toString(),
|
||||
offset: pagination.offset.toString(),
|
||||
})
|
||||
|
||||
if (filters.action) params.append('action', filters.action)
|
||||
if (filters.resourceType) params.append('resourceType', filters.resourceType)
|
||||
if (filters.userId) params.append('userId', filters.userId)
|
||||
|
||||
const response = await authFetch(`/api/audit-logs?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Audit-Logs')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Audit-Logs Response:', data)
|
||||
console.log('Anzahl Logs:', data.logs?.length || 0)
|
||||
setLogs(data.logs || [])
|
||||
setPagination({
|
||||
...pagination,
|
||||
total: data.total || 0,
|
||||
hasMore: data.hasMore || false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error fetching audit logs:', err)
|
||||
if (!silent) {
|
||||
setError('Fehler beim Laden der Audit-Logs')
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs()
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||
|
||||
// Automatische Aktualisierung alle 5 Sekunden (silent, ohne Loading-State)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
fetchLogs(true) // Silent update - kein Loading-State
|
||||
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
setPagination({ ...pagination, offset: 0 })
|
||||
}
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (pagination.offset > 0) {
|
||||
setPagination({
|
||||
...pagination,
|
||||
offset: Math.max(0, pagination.offset - pagination.limit),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.hasMore) {
|
||||
setPagination({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
try {
|
||||
if (!timestamp) {
|
||||
return { date: '-', time: '-' }
|
||||
}
|
||||
|
||||
// Handle ISO format (2025-11-20T16:45:22Z)
|
||||
if (timestamp.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) {
|
||||
const [datePart, timePart] = timestamp.split('T')
|
||||
const timeOnly = timePart.split(/[Z+-]/)[0] // Entferne Zeitzone-Info (Z, +, -)
|
||||
|
||||
return {
|
||||
date: datePart, // 2025-11-20
|
||||
time: timeOnly // 16:45:22
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SQLite DATETIME format (YYYY-MM-DD HH:MM:SS)
|
||||
if (timestamp.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||
const [datePart, timePart] = timestamp.split(' ')
|
||||
|
||||
return {
|
||||
date: datePart, // 2025-11-20
|
||||
time: timePart // 16:45:22
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback für andere Formate
|
||||
return { date: timestamp, time: '-' }
|
||||
} catch (error) {
|
||||
// Fallback: Zeige den Timestamp direkt an
|
||||
console.error('Fehler beim Formatieren des Timestamps:', error, timestamp)
|
||||
return { date: timestamp || '-', time: '-' }
|
||||
}
|
||||
}
|
||||
|
||||
const getActionColor = (action) => {
|
||||
const colors = {
|
||||
CREATE: 'text-green-400',
|
||||
UPDATE: 'text-blue-400',
|
||||
DELETE: 'text-red-400',
|
||||
UPLOAD: 'text-yellow-400',
|
||||
SIGN: 'text-purple-400',
|
||||
ENABLE: 'text-green-400',
|
||||
DISABLE: 'text-orange-400',
|
||||
}
|
||||
return colors[action] || 'text-slate-300'
|
||||
}
|
||||
|
||||
const actionLabels = {
|
||||
CREATE: 'Erstellt',
|
||||
UPDATE: 'Aktualisiert',
|
||||
DELETE: 'Gelöscht',
|
||||
UPLOAD: 'Hochgeladen',
|
||||
SIGN: 'Signiert',
|
||||
ENABLE: 'Aktiviert',
|
||||
DISABLE: 'Deaktiviert',
|
||||
}
|
||||
|
||||
const resourceTypeLabels = {
|
||||
user: 'Benutzer',
|
||||
space: 'Space',
|
||||
fqdn: 'FQDN',
|
||||
csr: 'CSR',
|
||||
provider: 'Provider',
|
||||
certificate: 'Zertifikat',
|
||||
}
|
||||
|
||||
const toggleLogExpansion = (logId) => {
|
||||
setExpandedLogs(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(logId)) {
|
||||
newSet.delete(logId)
|
||||
} else {
|
||||
newSet.add(logId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const formatDetails = (details) => {
|
||||
if (!details) return '-'
|
||||
try {
|
||||
const parsed = typeof details === 'string' ? JSON.parse(details) : details
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return details
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
||||
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Live-Aktualisierung aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Aktion
|
||||
</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Aktionen</option>
|
||||
<option value="CREATE">Erstellt</option>
|
||||
<option value="UPDATE">Aktualisiert</option>
|
||||
<option value="DELETE">Gelöscht</option>
|
||||
<option value="UPLOAD">Hochgeladen</option>
|
||||
<option value="SIGN">Signiert</option>
|
||||
<option value="ENABLE">Aktiviert</option>
|
||||
<option value="DISABLE">Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Ressourcentyp
|
||||
</label>
|
||||
<select
|
||||
value={filters.resourceType}
|
||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="space">Space</option>
|
||||
<option value="fqdn">FQDN</option>
|
||||
<option value="csr">CSR</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Benutzer-ID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.userId}
|
||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||
placeholder="Benutzer-ID filtern"
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs Table */}
|
||||
{error && (
|
||||
<div className="bg-red-500/20 border border-red-500/50 rounded-lg p-4 mb-6 text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-8 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 Audit-Logs...</p>
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-8 text-center">
|
||||
<p className="text-slate-300">Keine Audit-Logs gefunden</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider w-12">
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Zeitstempel
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Benutzer
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Aktion
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Ressource
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
IP-Adresse
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{logs.map((log) => {
|
||||
const isExpanded = expandedLogs.has(log.id)
|
||||
return (
|
||||
<>
|
||||
<tr key={log.id} className="hover:bg-slate-700/30 transition-colors cursor-pointer" onClick={() => toggleLogExpansion(log.id)}>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isExpanded ? '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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
<span className="font-mono text-xs">
|
||||
{formatTimestamp(log.timestamp).date} // {formatTimestamp(log.timestamp).time || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
{log.username || log.userId || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`font-semibold ${getActionColor(log.action)}`}>
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<span className="text-slate-400">
|
||||
{resourceTypeLabels[log.resourceType] || log.resourceType}
|
||||
</span>
|
||||
{log.resourceId && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
({log.resourceId.substring(0, 8)}...)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-300 max-w-md truncate">
|
||||
{log.details ? (
|
||||
<span className="truncate block">
|
||||
{typeof log.details === 'string' && log.details.length > 50
|
||||
? log.details.substring(0, 50) + '...'
|
||||
: log.details}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">
|
||||
{log.ipAddress || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${log.id}-details`} className="bg-slate-800/50">
|
||||
<td colSpan="7" className="px-4 py-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-2">Vollständige Details</h4>
|
||||
<pre className="bg-slate-900/50 border border-slate-700/50 rounded-lg p-4 text-xs text-slate-300 overflow-x-auto">
|
||||
{formatDetails(log.details)}
|
||||
</pre>
|
||||
</div>
|
||||
{log.userAgent && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">User-Agent</h4>
|
||||
<p className="text-xs text-slate-300 break-all">{log.userAgent}</p>
|
||||
</div>
|
||||
)}
|
||||
{log.resourceId && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">Ressourcen-ID</h4>
|
||||
<p className="text-xs text-slate-300 font-mono">{log.resourceId}</p>
|
||||
</div>
|
||||
)}
|
||||
{log.userId && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-400 uppercase mb-1">Benutzer-ID</h4>
|
||||
<p className="text-xs text-slate-300 font-mono">{log.userId}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-slate-300">
|
||||
Zeige {pagination.offset + 1} - {Math.min(pagination.offset + pagination.limit, pagination.total)} von {pagination.total} Einträgen
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handlePreviousPage}
|
||||
disabled={pagination.offset === 0}
|
||||
className="px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!pagination.hasMore}
|
||||
className="px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuditLogs
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import ProvidersSection from '../components/ProvidersSection'
|
||||
|
||||
const Home = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [data, setData] = useState(null)
|
||||
const [stats, setStats] = useState(null)
|
||||
const [loadingStats, setLoadingStats] = useState(true)
|
||||
@@ -15,7 +17,7 @@ const Home = () => {
|
||||
if (isInitial) {
|
||||
setLoadingStats(true)
|
||||
}
|
||||
const response = await fetch('/api/stats')
|
||||
const response = await authFetch('/api/stats')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
@@ -62,7 +64,7 @@ const Home = () => {
|
||||
// Tab is visible, resume polling
|
||||
if (!intervalRef.current && isMountedRef.current) {
|
||||
// Fetch immediately when tab becomes visible
|
||||
fetch('/api/stats')
|
||||
authFetch('/api/stats')
|
||||
.then(res => res.json())
|
||||
.then(statsData => {
|
||||
if (isMountedRef.current) {
|
||||
@@ -84,7 +86,7 @@ const Home = () => {
|
||||
// Resume polling
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (isMountedRef.current) {
|
||||
fetch('/api/stats')
|
||||
authFetch('/api/stats')
|
||||
.then(res => res.json())
|
||||
.then(statsData => {
|
||||
if (isMountedRef.current) {
|
||||
@@ -112,7 +114,7 @@ const Home = () => {
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
}, [authFetch])
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
@@ -123,7 +125,7 @@ const Home = () => {
|
||||
if (isInitial) {
|
||||
setLoadingStats(true)
|
||||
}
|
||||
const response = await fetch('/api/stats')
|
||||
const response = await authFetch('/api/stats')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
@@ -176,7 +178,7 @@ const Home = () => {
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
}, []) // Empty dependency array - only run on mount
|
||||
}, [authFetch]) // Include authFetch in dependencies
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
|
||||
168
frontend/src/pages/Login.jsx
Normal file
168
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login, isAuthenticated } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
// Wenn bereits eingeloggt, weiterleiten
|
||||
if (isAuthenticated) {
|
||||
navigate('/')
|
||||
}
|
||||
}, [isAuthenticated, navigate])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Bitte geben Sie Benutzername und Passwort ein')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Login-Versuch:', { username })
|
||||
|
||||
try {
|
||||
const result = await login(username, password)
|
||||
setLoading(false)
|
||||
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
} else {
|
||||
console.error('Login fehlgeschlagen:', result.error)
|
||||
setError(result.error || 'Ungültige Anmeldedaten')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login-Fehler:', err)
|
||||
setLoading(false)
|
||||
setError('Fehler bei der Anmeldung. Bitte versuchen Sie es erneut.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-800 via-slate-900 to-slate-800 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl mb-4 shadow-lg">
|
||||
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Certigo Addon</h1>
|
||||
<p className="text-slate-400">Melden Sie sich an, um fortzufahren</p>
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-slate-800/90 backdrop-blur-sm rounded-2xl shadow-2xl border border-slate-700/50 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Benutzername
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Benutzername eingeben"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-3 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-400 mr-2" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed 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 angemeldet...
|
||||
</>
|
||||
) : (
|
||||
'Anmelden'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Default Credentials Hint */}
|
||||
<div className="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<p className="text-xs text-blue-300 text-center">
|
||||
<span className="font-semibold">Standard-Anmeldedaten:</span><br />
|
||||
Benutzername: <code className="bg-slate-700/50 px-1 py-0.5 rounded">admin</code><br />
|
||||
Passwort: <code className="bg-slate-700/50 px-1 py-0.5 rounded">admin</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-slate-500 text-sm mt-6">
|
||||
© {new Date().getFullYear()} Certigo Addon. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
||||
669
frontend/src/pages/Profile.jsx
Normal file
669
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,669 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Profile = () => {
|
||||
const { authFetch, user } = useAuth()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuccessAnimation, setShowSuccessAnimation] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||
const [showCropModal, setShowCropModal] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState(null)
|
||||
const [cropImage, setCropImage] = useState(null)
|
||||
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 })
|
||||
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, size: 200 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
// Lade Profilbild
|
||||
loadAvatar()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const loadAvatar = async () => {
|
||||
if (user?.id) {
|
||||
// Versuche Profilbild zu laden, mit Timestamp für Cache-Busting
|
||||
const url = `/api/users/${user.id}/avatar?t=${Date.now()}`
|
||||
try {
|
||||
const response = await authFetch(url)
|
||||
if (response.ok) {
|
||||
setAvatarUrl(url)
|
||||
} else {
|
||||
setAvatarUrl(null)
|
||||
}
|
||||
} catch {
|
||||
setAvatarUrl(null)
|
||||
}
|
||||
} else {
|
||||
setAvatarUrl(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validiere Dateityp
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError('Nur Bilddateien (JPEG, PNG, GIF, WebP) sind erlaubt')
|
||||
return
|
||||
}
|
||||
|
||||
// Validiere Dateigröße (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('Datei ist zu groß. Maximale Größe: 10MB')
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedFile(file)
|
||||
|
||||
// Lade Bild für Crop-Modal
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setCropImage(e.target.result)
|
||||
setShowCropModal(true)
|
||||
// Setze initiale Crop-Position (zentriert)
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const minSize = Math.min(img.width, img.height)
|
||||
const cropSize = Math.min(minSize * 0.8, 400)
|
||||
setImageDimensions({ width: img.width, height: img.height })
|
||||
setCropPosition({
|
||||
x: (img.width - cropSize) / 2,
|
||||
y: (img.height - cropSize) / 2,
|
||||
size: cropSize
|
||||
})
|
||||
}
|
||||
img.src = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleCropDragStart = (e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
const img = document.getElementById('crop-image')
|
||||
if (!img) return
|
||||
|
||||
const rect = img.getBoundingClientRect()
|
||||
const scaleX = imageDimensions.width / rect.width
|
||||
const scaleY = imageDimensions.height / rect.height
|
||||
|
||||
setDragStart({
|
||||
x: e.clientX - (cropPosition.x / scaleX + rect.left),
|
||||
y: e.clientY - (cropPosition.y / scaleY + rect.top)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCropDrag = (e) => {
|
||||
if (!isDragging) return
|
||||
|
||||
const img = document.getElementById('crop-image')
|
||||
if (!img) return
|
||||
|
||||
const rect = img.getBoundingClientRect()
|
||||
const scaleX = imageDimensions.width / rect.width
|
||||
const scaleY = imageDimensions.height / rect.height
|
||||
|
||||
const newX = (e.clientX - rect.left - dragStart.x) * scaleX
|
||||
const newY = (e.clientY - rect.top - dragStart.y) * scaleY
|
||||
|
||||
// Begrenze auf Bildgrenzen
|
||||
const maxX = imageDimensions.width - cropPosition.size
|
||||
const maxY = imageDimensions.height - cropPosition.size
|
||||
|
||||
setCropPosition(prev => ({
|
||||
...prev,
|
||||
x: Math.max(0, Math.min(maxX, newX)),
|
||||
y: Math.max(0, Math.min(maxY, newY))
|
||||
}))
|
||||
}
|
||||
|
||||
const handleCropDragEnd = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleCropResize = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsResizing(true)
|
||||
|
||||
const img = document.getElementById('crop-image')
|
||||
if (!img) return
|
||||
|
||||
const rect = img.getBoundingClientRect()
|
||||
const scale = Math.min(imageDimensions.width / rect.width, imageDimensions.height / rect.height)
|
||||
const startY = e.clientY
|
||||
const startSize = cropPosition.size
|
||||
const startX = cropPosition.x
|
||||
const startYPos = cropPosition.y
|
||||
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
const deltaY = (moveEvent.clientY - startY) * scale
|
||||
const newSize = Math.max(50, Math.min(
|
||||
Math.min(imageDimensions.width, imageDimensions.height),
|
||||
startSize - deltaY
|
||||
))
|
||||
|
||||
// Zentriere Crop-Bereich bei Größenänderung
|
||||
const maxX = imageDimensions.width - newSize
|
||||
const maxY = imageDimensions.height - newSize
|
||||
|
||||
setCropPosition({
|
||||
x: Math.max(0, Math.min(maxX, startX + (startSize - newSize) / 2)),
|
||||
y: Math.max(0, Math.min(maxY, startYPos + (startSize - newSize) / 2)),
|
||||
size: newSize
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
|
||||
const cropImageToCircle = async () => {
|
||||
if (!cropImage) return null
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const size = cropPosition.size
|
||||
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
// Erstelle kreisförmigen Clip-Pfad
|
||||
ctx.beginPath()
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI)
|
||||
ctx.clip()
|
||||
|
||||
// Zeichne zugeschnittenes Bild
|
||||
ctx.drawImage(
|
||||
img,
|
||||
cropPosition.x, cropPosition.y, cropPosition.size, cropPosition.size,
|
||||
0, 0, size, size
|
||||
)
|
||||
|
||||
// Konvertiere zu Blob
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob)
|
||||
}, 'image/png', 0.95)
|
||||
}
|
||||
img.src = cropImage
|
||||
})
|
||||
}
|
||||
|
||||
const handleCropConfirm = async () => {
|
||||
setUploadingAvatar(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
try {
|
||||
const croppedBlob = await cropImageToCircle()
|
||||
if (!croppedBlob) {
|
||||
setError('Fehler beim Zuschneiden des Bildes')
|
||||
setUploadingAvatar(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Erstelle File aus Blob
|
||||
const file = new File([croppedBlob], selectedFile.name, { type: 'image/png' })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('avatar', file)
|
||||
|
||||
const response = await authFetch(`/api/users/${user.id}/avatar`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setSuccess('Profilbild erfolgreich hochgeladen')
|
||||
setShowCropModal(false)
|
||||
setSelectedFile(null)
|
||||
setCropImage(null)
|
||||
// Lade Profilbild neu
|
||||
loadAvatar()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Hochladen des Profilbilds')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Hochladen des Profilbilds')
|
||||
console.error('Error uploading avatar:', err)
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setShowSuccessAnimation(false)
|
||||
setLoading(true)
|
||||
|
||||
// Validierung: Passwort-Bestätigung muss übereinstimmen
|
||||
if (formData.password && formData.password !== formData.confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validierung: Wenn Passwort geändert wird, muss altes Passwort vorhanden sein
|
||||
if (formData.password && !formData.oldPassword) {
|
||||
setError('Bitte geben Sie das alte Passwort ein, um das Passwort zu ändern')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
...(formData.username && { username: formData.username }),
|
||||
...(formData.email && { email: formData.email }),
|
||||
...(formData.password && {
|
||||
password: formData.password,
|
||||
oldPassword: formData.oldPassword
|
||||
})
|
||||
}
|
||||
|
||||
const response = await authFetch(`/api/users/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setShowSuccessAnimation(true)
|
||||
// Warte kurz, damit Animation sichtbar ist
|
||||
setTimeout(() => {
|
||||
setShowSuccessAnimation(false)
|
||||
// Aktualisiere User-Daten im AuthContext
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Aktualisieren des Profils')
|
||||
setShowSuccessAnimation(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren des Profils')
|
||||
setShowSuccessAnimation(false)
|
||||
console.error('Error updating profile:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
// Clear success/error messages when user starts typing
|
||||
if (success) setSuccess('')
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
||||
<p className="text-slate-300">Lade Profil...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Mein Profil</h1>
|
||||
<p className="text-lg text-slate-200">
|
||||
Verwalten Sie Ihre persönlichen Daten und Einstellungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
{/* Profilbild */}
|
||||
<div className="flex items-center gap-6 mb-8 pb-8 border-b border-slate-700/50">
|
||||
<div className="relative">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Profilbild"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-slate-600"
|
||||
onError={() => {
|
||||
// Wenn Bild nicht geladen werden kann, setze avatarUrl auf null
|
||||
setAvatarUrl(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-slate-700/50 border-2 border-slate-600 flex items-center justify-center">
|
||||
<span className="text-4xl text-slate-400">👤</span>
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white text-sm transition-colors cursor-pointer shadow-lg"
|
||||
title="Profilbild ändern"
|
||||
>
|
||||
{uploadingAvatar ? (
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
'📷'
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={uploadingAvatar}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-1">{user.username}</h2>
|
||||
<p className="text-slate-300">{user.email}</p>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
{avatarUrl ? 'Klicken Sie auf das Kamera-Icon, um Ihr Profilbild zu ändern' : 'Klicken Sie auf das Kamera-Icon, um ein Profilbild hochzuladen'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profil bearbeiten Formular */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Geben Sie Ihren Benutzernamen ein"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Geben Sie Ihre E-Mail-Adresse ein"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-700/50">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Passwort ändern</h3>
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
Lassen Sie die Felder leer, wenn Sie Ihr Passwort nicht ändern möchten.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="oldPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Altes Passwort {formData.password && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="oldPassword"
|
||||
name="oldPassword"
|
||||
value={formData.oldPassword}
|
||||
onChange={handleChange}
|
||||
required={!!formData.password}
|
||||
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 Ihr aktuelles Passwort ein"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
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 ein neues Passwort ein"
|
||||
/>
|
||||
{/* Passwortrichtlinie - nur anzeigen wenn Passwort eingegeben wird */}
|
||||
{formData.password && (
|
||||
<div className="mt-2 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
|
||||
<p className="text-xs font-semibold text-slate-300 mb-2">Passwortrichtlinie:</p>
|
||||
<ul className="text-xs text-slate-400 space-y-1">
|
||||
<li className={`flex items-center gap-2 ${formData.password.length >= 8 ? 'text-green-400' : ''}`}>
|
||||
{formData.password.length >= 8 ? '✓' : '○'} Mindestens 8 Zeichen
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[A-Z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[A-Z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Großbuchstabe
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[a-z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[a-z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Kleinbuchstabe
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[0-9]/.test(formData.password) ? '✓' : '○'} Mindestens eine Zahl
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[^A-Za-z0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[^A-Za-z0-9]/.test(formData.password) ? '✓' : '○'} Mindestens ein Sonderzeichen
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Neues Passwort bestätigen {formData.password && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required={!!formData.password}
|
||||
className={`w-full px-4 py-2 bg-slate-700/50 border rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||
? 'border-red-500'
|
||||
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||
? 'border-green-500'
|
||||
: 'border-slate-600'
|
||||
}`}
|
||||
placeholder="Bestätigen Sie das neue Passwort"
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-400">Die Passwörter stimmen nicht überein</p>
|
||||
)}
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && formData.password && (
|
||||
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || showSuccessAnimation}
|
||||
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-colors duration-200 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 gespeichert...
|
||||
</>
|
||||
)}
|
||||
{!loading && 'Profil aktualisieren'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Animation Popup */}
|
||||
{showSuccessAnimation && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-slate-800 rounded-lg shadow-2xl p-8 flex flex-col items-center">
|
||||
<div className="relative w-20 h-20 mb-4">
|
||||
{/* Ping Animation */}
|
||||
<div className="absolute inset-0 bg-green-500 rounded-full animate-ping opacity-75"></div>
|
||||
{/* Checkmark Circle */}
|
||||
<div className="relative w-20 h-20 bg-green-500 rounded-full flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white">Profil erfolgreich aktualisiert</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crop Modal */}
|
||||
{showCropModal && cropImage && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Profilbild zuschneiden</h2>
|
||||
<p className="text-slate-300 mb-6">
|
||||
Verschieben Sie den Kreis, um den gewünschten Bereich auszuwählen. Ziehen Sie an den Ecken, um die Größe zu ändern.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseMove={(e) => {
|
||||
if (isDragging) handleCropDrag(e)
|
||||
if (isResizing) return
|
||||
}}
|
||||
onMouseUp={handleCropDragEnd}
|
||||
onMouseLeave={handleCropDragEnd}
|
||||
>
|
||||
<img
|
||||
id="crop-image"
|
||||
src={cropImage}
|
||||
alt="Zu schneidendes Bild"
|
||||
className="max-w-full h-auto block"
|
||||
style={{ maxHeight: '70vh' }}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Crop-Bereich (Kreis) */}
|
||||
{imageDimensions.width > 0 && (
|
||||
<div
|
||||
className="absolute border-4 border-blue-500 rounded-full cursor-move"
|
||||
style={{
|
||||
left: `${(cropPosition.x / imageDimensions.width) * 100}%`,
|
||||
top: `${(cropPosition.y / imageDimensions.height) * 100}%`,
|
||||
width: `${(cropPosition.size / imageDimensions.width) * 100}%`,
|
||||
height: `${(cropPosition.size / imageDimensions.height) * 100}%`,
|
||||
aspectRatio: '1 / 1',
|
||||
boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.5)',
|
||||
pointerEvents: isResizing ? 'none' : 'auto'
|
||||
}}
|
||||
onMouseDown={handleCropDragStart}
|
||||
>
|
||||
{/* Resize-Handles an den Ecken */}
|
||||
<div
|
||||
className="absolute -bottom-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||
onMouseDown={handleCropResize}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nwse-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||
onMouseDown={handleCropResize}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||
onMouseDown={handleCropResize}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-2 -left-2 w-6 h-6 bg-blue-500 rounded-full cursor-nesw-resize border-2 border-white z-10 hover:bg-blue-600"
|
||||
onMouseDown={handleCropResize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleCropConfirm}
|
||||
disabled={uploadingAvatar}
|
||||
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-colors"
|
||||
>
|
||||
{uploadingAvatar ? 'Wird hochgeladen...' : 'Zuschneiden und hochladen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCropModal(false)
|
||||
setSelectedFile(null)
|
||||
setCropImage(null)
|
||||
}}
|
||||
disabled={uploadingAvatar}
|
||||
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const SpaceDetail = () => {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { authFetch } = useAuth()
|
||||
const [space, setSpace] = useState(null)
|
||||
const [fqdns, setFqdns] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
@@ -47,7 +49,7 @@ const SpaceDetail = () => {
|
||||
const fetchSpace = async () => {
|
||||
try {
|
||||
setLoadingSpace(true)
|
||||
const response = await fetch('/api/spaces')
|
||||
const response = await authFetch('/api/spaces')
|
||||
if (response.ok) {
|
||||
const spaces = await response.json()
|
||||
const foundSpace = spaces.find(s => s.id === id)
|
||||
@@ -70,7 +72,7 @@ const SpaceDetail = () => {
|
||||
const fetchFqdns = async () => {
|
||||
try {
|
||||
setFetchError('')
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns`)
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setFqdns(Array.isArray(data) ? data : [])
|
||||
@@ -108,7 +110,7 @@ const SpaceDetail = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns`, {
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -165,7 +167,7 @@ const SpaceDetail = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdnToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -216,7 +218,7 @@ const SpaceDetail = () => {
|
||||
formData.append('fqdn', fqdn.fqdn)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`, {
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
@@ -254,7 +256,7 @@ const SpaceDetail = () => {
|
||||
|
||||
const fetchCSR = async (fqdn) => {
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
if (response.ok) {
|
||||
const csr = await response.json()
|
||||
if (csr) {
|
||||
@@ -278,7 +280,7 @@ const SpaceDetail = () => {
|
||||
// Lade neuesten CSR und alle CSRs für History
|
||||
try {
|
||||
// Lade neuesten CSR
|
||||
const latestResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||
const latestResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||
if (latestResponse.ok) {
|
||||
const csr = await latestResponse.json()
|
||||
setCsrData(csr || null)
|
||||
@@ -287,7 +289,7 @@ const SpaceDetail = () => {
|
||||
}
|
||||
|
||||
// Lade alle CSRs für History
|
||||
const historyResponse = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
const historyResponse = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
if (historyResponse.ok) {
|
||||
const history = await historyResponse.json()
|
||||
setCsrHistory(Array.isArray(history) ? history : [])
|
||||
@@ -330,7 +332,7 @@ const SpaceDetail = () => {
|
||||
|
||||
// Lade neuesten CSR
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr?latest=true`)
|
||||
if (response.ok) {
|
||||
const csr = await response.json()
|
||||
setCsrData(csr)
|
||||
@@ -341,7 +343,7 @@ const SpaceDetail = () => {
|
||||
|
||||
// Lade Provider
|
||||
try {
|
||||
const response = await fetch('/api/providers')
|
||||
const response = await authFetch('/api/providers')
|
||||
if (response.ok) {
|
||||
const providersData = await response.json()
|
||||
setProviders(providersData.filter(p => p.enabled))
|
||||
@@ -356,7 +358,7 @@ const SpaceDetail = () => {
|
||||
const handleTestProvider = async (providerId) => {
|
||||
setProviderTestResult(null)
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${providerId}/test`, {
|
||||
const response = await authFetch(`/api/providers/${providerId}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
@@ -375,7 +377,7 @@ const SpaceDetail = () => {
|
||||
setSignResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/csr/sign`, {
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/csr/sign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -389,7 +391,7 @@ const SpaceDetail = () => {
|
||||
if (result.success) {
|
||||
// Lade Zertifikate automatisch neu, um das neue Zertifikat anzuzeigen
|
||||
try {
|
||||
const certResponse = await fetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates`)
|
||||
const certResponse = await authFetch(`/api/spaces/${id}/fqdns/${selectedFqdn.id}/certificates`)
|
||||
if (certResponse.ok) {
|
||||
const certs = await certResponse.json()
|
||||
setCertificates(certs)
|
||||
@@ -420,7 +422,7 @@ const SpaceDetail = () => {
|
||||
setCertificates([])
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/certificates`)
|
||||
if (response.ok) {
|
||||
const certs = await response.json()
|
||||
setCertificates(certs)
|
||||
@@ -438,7 +440,7 @@ const SpaceDetail = () => {
|
||||
const handleRefreshCertificate = async (cert) => {
|
||||
setRefreshingCertificate(cert.id)
|
||||
try {
|
||||
const response = await fetch(`/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'
|
||||
})
|
||||
if (response.ok) {
|
||||
@@ -766,7 +768,7 @@ const SpaceDetail = () => {
|
||||
if (!isOpen) {
|
||||
// Lade CSR-History wenn Bereich erweitert wird
|
||||
try {
|
||||
const response = await fetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
const response = await authFetch(`/api/spaces/${id}/fqdns/${fqdn.id}/csr`)
|
||||
if (response.ok) {
|
||||
const history = await response.json()
|
||||
// Speichere History mit FQDN-ID als Key
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Spaces = () => {
|
||||
const navigate = useNavigate()
|
||||
const { authFetch } = useAuth()
|
||||
const [spaces, setSpaces] = useState([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -26,7 +28,7 @@ const Spaces = () => {
|
||||
const fetchSpaces = async () => {
|
||||
try {
|
||||
setFetchError('')
|
||||
const response = await fetch('/api/spaces')
|
||||
const response = await authFetch('/api/spaces')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Stelle sicher, dass data ein Array ist
|
||||
@@ -57,7 +59,7 @@ const Spaces = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/spaces', {
|
||||
const response = await authFetch('/api/spaces', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -93,7 +95,7 @@ const Spaces = () => {
|
||||
// Hole die Anzahl der FQDNs für diesen Space
|
||||
let count = 0
|
||||
try {
|
||||
const countResponse = await fetch(`/api/spaces/${space.id}/fqdns/count`)
|
||||
const countResponse = await authFetch(`/api/spaces/${space.id}/fqdns/count`)
|
||||
if (countResponse.ok) {
|
||||
const countData = await countResponse.json()
|
||||
count = countData.count || 0
|
||||
@@ -127,7 +129,7 @@ const Spaces = () => {
|
||||
? `/api/spaces/${spaceToDelete.id}?deleteFqdns=true`
|
||||
: `/api/spaces/${spaceToDelete.id}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
const response = await authFetch(url, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -266,7 +268,7 @@ const Spaces = () => {
|
||||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||
<p className="text-red-300 mb-2">{fetchError}</p>
|
||||
<button
|
||||
onClick={fetchSpaces}
|
||||
onClick={() => fetchSpaces()}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all duration-200"
|
||||
>
|
||||
Erneut versuchen
|
||||
|
||||
385
frontend/src/pages/Users.jsx
Normal file
385
frontend/src/pages/Users.jsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Users = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setError('')
|
||||
const response = await authFetch('/api/users')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setUsers(Array.isArray(data) ? data : [])
|
||||
} else {
|
||||
setError('Fehler beim Abrufen der Benutzer')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Abrufen der Benutzer')
|
||||
console.error('Error fetching users:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
// Validierung: Passwort-Bestätigung muss übereinstimmen
|
||||
if (formData.password && formData.password !== formData.confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validierung: Wenn Passwort geändert wird, muss altes Passwort vorhanden sein
|
||||
if (editingUser && formData.password && !formData.oldPassword) {
|
||||
setError('Bitte geben Sie das alte Passwort ein, um das Passwort zu ändern')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = editingUser
|
||||
? `/api/users/${editingUser.id}`
|
||||
: '/api/users'
|
||||
const method = editingUser ? 'PUT' : 'POST'
|
||||
|
||||
const body = editingUser
|
||||
? {
|
||||
...(formData.username && { username: formData.username }),
|
||||
...(formData.email && { email: formData.email }),
|
||||
...(formData.password && {
|
||||
password: formData.password,
|
||||
oldPassword: formData.oldPassword
|
||||
})
|
||||
}
|
||||
: {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
}
|
||||
|
||||
const response = await authFetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUsers()
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||
setShowForm(false)
|
||||
setEditingUser(null)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || 'Fehler beim Speichern des Benutzers')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern des Benutzers')
|
||||
console.error('Error saving user:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setEditingUser(user)
|
||||
setFormData({
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (userId) => {
|
||||
if (!window.confirm('Möchten Sie diesen Benutzer wirklich löschen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authFetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchUsers()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
alert(errorData.error || 'Fehler beim Löschen des Benutzers')
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Fehler beim Löschen des Benutzers')
|
||||
console.error('Error deleting user:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Benutzerverwaltung</h1>
|
||||
<p className="text-lg text-slate-200">
|
||||
Verwalten Sie lokale Benutzer und deren Zugangsdaten.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingUser(null)
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||
}}
|
||||
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"
|
||||
>
|
||||
{showForm ? 'Abbrechen' : '+ Neuer Benutzer'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit User 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">
|
||||
{editingUser ? 'Benutzer bearbeiten' : 'Neuen Benutzer erstellen'}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Benutzername {!editingUser && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Geben Sie einen Benutzernamen ein"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
E-Mail {!editingUser && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Geben Sie eine E-Mail-Adresse ein"
|
||||
/>
|
||||
</div>
|
||||
{editingUser && (
|
||||
<div>
|
||||
<label htmlFor="oldPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Altes Passwort {formData.password && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="oldPassword"
|
||||
name="oldPassword"
|
||||
value={formData.oldPassword}
|
||||
onChange={handleChange}
|
||||
required={!!formData.password}
|
||||
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 Ihr aktuelles Passwort ein"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
{editingUser ? 'Neues Passwort' : 'Passwort'} {!editingUser && '*'}
|
||||
{editingUser && <span className="text-xs text-slate-400 ml-2">(leer lassen, um nicht zu ändern)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required={!editingUser}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder={editingUser ? "Geben Sie ein neues Passwort ein" : "Geben Sie ein Passwort ein"}
|
||||
/>
|
||||
{/* Passwortrichtlinie - nur anzeigen wenn Passwort eingegeben wird */}
|
||||
{(formData.password || !editingUser) && (
|
||||
<div className="mt-2 p-3 bg-slate-700/30 border border-slate-600/50 rounded-lg">
|
||||
<p className="text-xs font-semibold text-slate-300 mb-2">Passwortrichtlinie:</p>
|
||||
<ul className="text-xs text-slate-400 space-y-1">
|
||||
<li className={`flex items-center gap-2 ${formData.password.length >= 8 ? 'text-green-400' : ''}`}>
|
||||
{formData.password.length >= 8 ? '✓' : '○'} Mindestens 8 Zeichen
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[A-Z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[A-Z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Großbuchstabe
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[a-z]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[a-z]/.test(formData.password) ? '✓' : '○'} Mindestens ein Kleinbuchstabe
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[0-9]/.test(formData.password) ? '✓' : '○'} Mindestens eine Zahl
|
||||
</li>
|
||||
<li className={`flex items-center gap-2 ${/[^A-Za-z0-9]/.test(formData.password) ? 'text-green-400' : ''}`}>
|
||||
{/[^A-Za-z0-9]/.test(formData.password) ? '✓' : '○'} Mindestens ein Sonderzeichen
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-200 mb-2">
|
||||
{editingUser ? 'Neues Passwort bestätigen' : 'Passwort bestätigen'} {!editingUser && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required={!editingUser || !!formData.password}
|
||||
className={`w-full px-4 py-2 bg-slate-700/50 border rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
formData.confirmPassword && formData.password !== formData.confirmPassword
|
||||
? 'border-red-500'
|
||||
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||
? 'border-green-500'
|
||||
: 'border-slate-600'
|
||||
}`}
|
||||
placeholder={editingUser ? "Bestätigen Sie das neue Passwort" : "Bestätigen Sie das Passwort"}
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
<p className="mt-1 text-xs text-red-400">Die Passwörter stimmen nicht überein</p>
|
||||
)}
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && formData.password && (
|
||||
<p className="mt-1 text-xs text-green-400">✓ Passwörter stimmen überein</p>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
||||
{error}
|
||||
</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-colors duration-200"
|
||||
>
|
||||
{loading ? 'Wird gespeichert...' : editingUser ? 'Aktualisieren' : 'Benutzer erstellen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowForm(false)
|
||||
setEditingUser(null)
|
||||
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '' })
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users List */}
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6">
|
||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
||||
Benutzer
|
||||
</h2>
|
||||
{error && !showForm && (
|
||||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||
<p className="text-red-300 mb-2">{error}</p>
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{users.length === 0 ? (
|
||||
<p className="text-slate-300 text-center py-8">
|
||||
Noch keine Benutzer vorhanden. Erstellen Sie Ihren ersten Benutzer!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="bg-slate-700/50 rounded-lg p-4 border border-slate-600/50 hover:border-slate-500 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{user.username}
|
||||
</h3>
|
||||
<p className="text-slate-300 mb-2">{user.email}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Erstellt: {user.createdAt ? new Date(user.createdAt).toLocaleString('de-DE') : 'Unbekannt'}
|
||||
</p>
|
||||
{user.id && (
|
||||
<p className="text-xs text-slate-500 font-mono mt-1">
|
||||
ID: {user.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
|
||||
Reference in New Issue
Block a user