security: clean repository without media files and sensitive data
- Removed area/ directory with 816MB of media files - Removed sensitive FTP credentials from Git history - Implemented .env.upload system for secure deployments - Added comprehensive .gitignore for future protection This commit represents a clean slate with all sensitive data removed.
This commit is contained in:
132
frontend/src/App.js
Normal file
132
frontend/src/App.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import { ConfigProvider, theme, Spin, Alert, Layout, App as AntApp, Tabs } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { getCurrentUser } from './services/api';
|
||||
import UserMenu from './components/UserMenu';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import ClientProjects from './pages/ClientProjects';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
import SmartProjectRoute from './components/SmartProjectRoute';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
const stored = localStorage.getItem('darkMode');
|
||||
return stored === 'true';
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (token && !user) {
|
||||
getCurrentUser().then(res => {
|
||||
if (res.success) {
|
||||
setUser(res.user);
|
||||
} else {
|
||||
setError(res.error?.message || 'Fehler beim Laden der Userdaten');
|
||||
localStorage.removeItem('jwt');
|
||||
}
|
||||
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<ConfigProvider theme={{ algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
|
||||
<AntApp>
|
||||
{loading ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Preview..." fullscreen />
|
||||
) : (
|
||||
<>
|
||||
{error && <Alert type="error" message={error} showIcon style={{ margin: 16 }} />}
|
||||
<LoginPage onLogin={() => setUser(null)} />
|
||||
</>
|
||||
)}
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
// Entferne alle ads-overview-Caches beim Logout
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('ads-overview-')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
localStorage.removeItem('jwt');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const handleToggleDarkMode = () => {
|
||||
setDarkMode((prev) => {
|
||||
localStorage.setItem('darkMode', !prev);
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#c792ff',
|
||||
colorText: darkMode ? '#fff' : '#000',
|
||||
colorBgBase: darkMode ? '#181818' : '#f5f5f5',
|
||||
colorBgContainer: darkMode ? '#1f1f1f' : '#fff',
|
||||
colorBorder: darkMode ? '#404040' : '#d9d9d9',
|
||||
borderRadius: 4,
|
||||
lineWidth: 0
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
defaultBg: darkMode ? '#c792ff' : '#c792ff',
|
||||
defaultHoverBg: darkMode ? '#edff5f' : '#edff5f',
|
||||
defaultHoverColor: darkMode ? '#001f1e' : '#001f1e',
|
||||
},
|
||||
Tabs: {
|
||||
itemColor: darkMode ? '#fff' : '#fff',
|
||||
background: darkMode ? '#1f1f1f' : '#001f1e',
|
||||
},
|
||||
},
|
||||
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<Router>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Kein globaler Header mehr, Header wird ggf. in einzelnen Seiten eingebunden */}
|
||||
<Layout.Content style={{ padding: 0 }}>
|
||||
<Routes>
|
||||
{user.role === 'admin' ? (
|
||||
<>
|
||||
<Route path="/" element={<AdminDashboard user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||
{/* Smart URL Resolution für Admin-Zugriff auf Client-URLs - MUSS VOR der 3-Parameter Route stehen */}
|
||||
<Route path=":project/:tab?" element={<SmartProjectRoute user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||
<Route path=":client/:project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route path="/" element={<ClientProjects user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||
<Route path="/login" element={<LoginPage onLogin={() => setUser(null)} />} />
|
||||
<Route path=":project/:tab?" element={<ProjectDetail user={user} darkMode={darkMode} onLogout={handleLogout} onToggleDarkMode={handleToggleDarkMode} />} />
|
||||
{/* Catch-All-Route für nicht gefundene Pfade */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Router>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
29
frontend/src/components/ClientsManagement.js
Normal file
29
frontend/src/components/ClientsManagement.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Spin } from 'antd';
|
||||
import { getAll } from '../services/entityService';
|
||||
|
||||
export default function ClientsManagement() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getAll('clients')
|
||||
.then(data => {
|
||||
const items = data.clients || {};
|
||||
const list = Object.entries(items).map(([name, obj]) => ({ key: name, ...obj }));
|
||||
setClients(list);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{ title: 'Name', dataIndex: 'key', key: 'key' },
|
||||
{ title: 'Ordner', dataIndex: 'dir', key: 'dir' }
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return <Table dataSource={clients} columns={columns} pagination={false} />;
|
||||
}
|
||||
184
frontend/src/components/FilePreview.js
Normal file
184
frontend/src/components/FilePreview.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
// Hilfsfunktion für Dateigröße
|
||||
export function formatFileSize(bytes) {
|
||||
if (typeof bytes !== 'number' || isNaN(bytes)) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// Hilfsfunktion für Video-Duration
|
||||
export function formatDuration(seconds) {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds)) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Datei-Vorschau-Komponente mit sofortiger Zoom-Skalierung und Loading-State
|
||||
export default function FilePreview({ file, zoom = 1, darkMode = false }) {
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const width = file.width || 300;
|
||||
const height = file.height || 200;
|
||||
|
||||
// Wrapper für saubere Skalierung
|
||||
const wrapperStyle = {
|
||||
display: 'inline-block',
|
||||
width: width * zoom,
|
||||
height: height * zoom,
|
||||
overflow: 'hidden',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
position: 'relative'
|
||||
};
|
||||
const innerStyle = {
|
||||
width,
|
||||
height,
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top left',
|
||||
display: 'block'
|
||||
};
|
||||
|
||||
const renderFileContent = () => {
|
||||
if (file.type === 'html') {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={innerStyle}>
|
||||
{!loaded && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||
borderRadius: 4
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={file.url}
|
||||
title={file.name}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (file.type === 'image') {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={innerStyle}>
|
||||
{!loaded && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||
borderRadius: 6
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (file.type === 'video') {
|
||||
return (
|
||||
<div style={wrapperStyle}>
|
||||
<div style={innerStyle}>
|
||||
{!loaded && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: width,
|
||||
height: height,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: darkMode ? '#2c2c2c' : '#ffffff',
|
||||
border: `1px solid ${darkMode ? '#404040' : '#d9d9d9'}`,
|
||||
borderRadius: 6
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
<video
|
||||
src={file.url}
|
||||
width={width}
|
||||
height={height}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
preload="metadata"
|
||||
style={{ border: 'none', opacity: loaded ? 1 : 0 }}
|
||||
onLoadedData={() => setLoaded(true)}
|
||||
onError={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Sonstige Datei: Link
|
||||
return (
|
||||
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginBottom: 16, marginTop: 16 }}>
|
||||
{renderFileContent()}
|
||||
{/* Badge für jede einzelne Datei */}
|
||||
{(file.width || file.height || file.size || file.duration) && (
|
||||
<div style={{
|
||||
marginTop: 0,
|
||||
background: darkMode ? 'rgba(31, 31, 31, 0.95)' : 'rgba(31, 31, 31, 0.95)',
|
||||
color: darkMode ? '#f9f9f9' : '#f9f9f9',
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
fontSize: 11,
|
||||
padding: '2px 6px',
|
||||
display: 'inline-block',
|
||||
lineHeight: 1.4,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{file.width && file.height ? `${file.width}×${file.height}px` : ''}
|
||||
{(file.width && file.height) && (file.size || file.duration) ? ' · ' : ''}
|
||||
{file.size ? formatFileSize(file.size) : ''}
|
||||
{file.size && file.duration ? ' · ' : ''}
|
||||
{file.type === 'video' && file.duration ? `${formatDuration(file.duration)} s` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/src/components/SmartProjectRoute.js
Normal file
106
frontend/src/components/SmartProjectRoute.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
import { LoadingOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import ProjectDetail from '../pages/ProjectDetail';
|
||||
import urlResolutionService from '../services/urlResolutionService';
|
||||
import debugLogger from '../utils/debugLogger';
|
||||
|
||||
// Smart Route Component für Admin URL Resolution
|
||||
export default function SmartProjectRoute({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||
const { project, tab } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [resolvedClient, setResolvedClient] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resolveUrl = async () => {
|
||||
debugLogger.routing('SmartProjectRoute - Resolving URL for:', { project, tab });
|
||||
|
||||
// Nur für Admins, die Client-URLs verwenden
|
||||
if (user.role !== 'admin') {
|
||||
debugLogger.routing('User ist kein Admin, keine URL Resolution');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugLogger.routing('Resolving client for project:', project);
|
||||
const clientName = await urlResolutionService.resolveClientForProject(project);
|
||||
|
||||
if (clientName) {
|
||||
debugLogger.success('Client resolved:', { project, clientName, tab });
|
||||
setResolvedClient(clientName);
|
||||
} else {
|
||||
debugLogger.warn('No client found for project:', project);
|
||||
setError(`Projekt "${project}" wurde keinem Client zugeordnet.`);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLogger.error('Error during URL resolution:', err);
|
||||
setError(`Fehler bei der URL-Resolution: ${err.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
resolveUrl();
|
||||
}, [project, tab, user.role]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
indicator={<LoadingOutlined spin />}
|
||||
size="large"
|
||||
tip="Löse URL auf..."
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
icon={<LockOutlined />}
|
||||
status="403"
|
||||
title="Keine Zugriffsberechtigung"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/')}>
|
||||
Zurück zum Dashboard
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Für Clients oder wenn keine Resolution nötig: Normale ProjectDetail
|
||||
if (user.role !== 'admin' || !resolvedClient) {
|
||||
return (
|
||||
<ProjectDetail
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
onLogout={onLogout}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Für Admins mit aufgelöster URL: ProjectDetail mit injiziertem Client-Parameter
|
||||
debugLogger.routing('Passing parameters to ProjectDetail:', { client: resolvedClient, project, tab });
|
||||
return (
|
||||
<ProjectDetail
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
onLogout={onLogout}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
// Inject resolved client parameter
|
||||
overrideParams={{ client: resolvedClient, project, tab }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
405
frontend/src/components/UserManagement.js
Normal file
405
frontend/src/components/UserManagement.js
Normal file
@@ -0,0 +1,405 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Button, Spin, Alert, Modal, Form, Input, Select, App, Tooltip } from 'antd';
|
||||
import { EditPencil, Trash } from 'iconoir-react';
|
||||
import { getAll, create, update, remove } from '../services/entityService';
|
||||
const { Option } = Select;
|
||||
|
||||
export default function UserManagement() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const { message } = App.useApp();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchClients();
|
||||
}, []);
|
||||
|
||||
function fetchUsers() {
|
||||
setLoading(true);
|
||||
getAll('users').then(res => {
|
||||
if (res.success && Array.isArray(res.users)) {
|
||||
setUsers(res.users);
|
||||
} else {
|
||||
setError(res.error?.message || 'Fehler beim Laden der Benutzer');
|
||||
}
|
||||
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function fetchClients() {
|
||||
getAll('clients').then(res => {
|
||||
if (res.success && res.clients) {
|
||||
// Extrahiere Client-Verzeichnisnamen (dir) aus der Response
|
||||
const clientDirs = Object.values(res.clients).map(client => client.dir);
|
||||
// Entferne Duplikate falls vorhanden
|
||||
const uniqueClientDirs = [...new Set(clientDirs)];
|
||||
setClients(uniqueClientDirs);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.warn('Fehler beim Laden der Clients:', err);
|
||||
setClients([]);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateUser(values) {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Stelle sicher, dass disallowedClients als Array gespeichert wird
|
||||
if (values.disallowedClients && !Array.isArray(values.disallowedClients)) {
|
||||
values.disallowedClients = [];
|
||||
}
|
||||
|
||||
const data = await create('users', values);
|
||||
if (data.success) {
|
||||
message.success('Benutzer angelegt');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(data.error?.message || 'Fehler beim Anlegen');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('Netzwerkfehler');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteUser(user) {
|
||||
modal.confirm({
|
||||
title: `Benutzer wirklich löschen?`,
|
||||
content: `Soll der Benutzer "${user.username}" wirklich gelöscht werden?`,
|
||||
okText: 'Löschen',
|
||||
okType: 'danger',
|
||||
cancelText: 'Abbrechen',
|
||||
onOk: async () => {
|
||||
const data = await remove('users', user.id);
|
||||
if (data.success) {
|
||||
message.success('Benutzer gelöscht');
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(data.error?.message || 'Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openEditModal(user) {
|
||||
setEditingUser(user);
|
||||
setEditModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleEditUser(values) {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Separater API-Call für Passwort-Update falls angegeben
|
||||
if (values.password) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const passwordResponse = await fetch(`/api/admin/users/${editingUser.id}/password`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ password: values.password })
|
||||
});
|
||||
|
||||
const passwordResult = await passwordResponse.json();
|
||||
if (!passwordResult.success) {
|
||||
message.error(passwordResult.error?.message || 'Fehler beim Ändern des Passworts');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard User-Daten aktualisieren (ohne Passwort)
|
||||
const { password, confirmPassword, ...userData } = values;
|
||||
|
||||
// Stelle sicher, dass disallowedClients als Array gespeichert wird
|
||||
if (userData.disallowedClients && !Array.isArray(userData.disallowedClients)) {
|
||||
userData.disallowedClients = [];
|
||||
}
|
||||
|
||||
const data = await update('users', editingUser.id, userData);
|
||||
|
||||
if (data.success) {
|
||||
message.success('Benutzer erfolgreich aktualisiert' + (values.password ? ' (inklusive Passwort)' : ''));
|
||||
setEditModalOpen(false);
|
||||
fetchUsers();
|
||||
} else {
|
||||
message.error(data.error?.message || 'Fehler beim Bearbeiten');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error('Netzwerkfehler');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'Benutzername', dataIndex: 'username', key: 'username' },
|
||||
{ title: 'Rolle', dataIndex: 'role', key: 'role' },
|
||||
{ title: 'E-Mail', dataIndex: 'email', key: 'email' },
|
||||
{
|
||||
title: 'Gesperrte Clients',
|
||||
key: 'disallowedClients',
|
||||
render: (_, record) => {
|
||||
if (record.role !== 'admin' || !record.disallowedClients || record.disallowedClients.length === 0) {
|
||||
return <span style={{ color: '#888', fontStyle: 'italic' }}>Keine</span>;
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{record.disallowedClients.map(client => (
|
||||
<span
|
||||
key={client}
|
||||
style={{
|
||||
background: '#ff4d4f',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{client}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Aktionen',
|
||||
key: 'actions',
|
||||
render: (_, record) => (
|
||||
<span style={{ display: 'flex', gap: '8px' }}>
|
||||
<Tooltip title="Benutzer bearbeiten">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditPencil />}
|
||||
onClick={() => openEditModal(record)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Benutzer löschen">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<Trash />}
|
||||
onClick={() => handleDeleteUser(record)}
|
||||
danger
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <Spin tip="Lade Benutzer..." fullscreen />;
|
||||
}
|
||||
if (error) {
|
||||
return <Alert type="error" message={error} showIcon />;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{modalContextHolder}
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => setModalOpen(true)}>
|
||||
Neuen Benutzer anlegen
|
||||
</Button>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" />
|
||||
<Modal
|
||||
title="Neuen Benutzer anlegen"
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreateUser}>
|
||||
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
|
||||
<Input autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
|
||||
<Select placeholder="Rolle wählen">
|
||||
<Option value="admin">Admin</Option>
|
||||
<Option value="client">Client</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Client-Berechtigungen nur für Admins anzeigen */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('role') === 'admin' ? (
|
||||
<Form.Item
|
||||
name="disallowedClients"
|
||||
label="Gesperrte Clients"
|
||||
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Clients auswählen, die gesperrt werden sollen"
|
||||
options={clients.map(client => ({
|
||||
label: client,
|
||||
value: client
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="Passwort"
|
||||
rules={[
|
||||
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Passwort eingeben (optional)" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||
Anlegen
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="Benutzer bearbeiten"
|
||||
open={editModalOpen}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
>
|
||||
<Form
|
||||
key={editingUser?.id}
|
||||
initialValues={{
|
||||
username: editingUser?.username,
|
||||
email: editingUser?.email,
|
||||
role: editingUser?.role,
|
||||
disallowedClients: editingUser?.disallowedClients || []
|
||||
// password und confirmPassword werden bewusst nicht gesetzt, damit sie leer beginnen
|
||||
}}
|
||||
layout="vertical"
|
||||
onFinish={handleEditUser}
|
||||
>
|
||||
<Form.Item name="username" label="Benutzername" rules={[{ required: true, message: 'Bitte Benutzernamen angeben' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="E-Mail" rules={[{ type: 'email', message: 'Ungültige E-Mail' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="Rolle" rules={[{ required: true, message: 'Bitte Rolle wählen' }]}>
|
||||
<Select placeholder="Rolle wählen">
|
||||
<Option value="admin">Admin</Option>
|
||||
<Option value="client">Client</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Client-Berechtigungen nur für Admins anzeigen */}
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.role !== currentValues.role}
|
||||
>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('role') === 'admin' ? (
|
||||
<Form.Item
|
||||
name="disallowedClients"
|
||||
label="Gesperrte Clients"
|
||||
tooltip="Wählen Sie die Clients aus, auf die dieser Administrator keinen Zugriff haben soll"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Clients auswählen, die gesperrt werden sollen"
|
||||
options={clients.map(client => ({
|
||||
label: client,
|
||||
value: client
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 24, marginBottom: 16, borderTop: '1px solid #f0f0f0', paddingTop: 16 }}>
|
||||
<h4>Passwort ändern (optional)</h4>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="Neues Passwort"
|
||||
rules={[
|
||||
{ min: 6, message: 'Passwort muss mindestens 6 Zeichen lang sein' }
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Neues Passwort eingeben (leer lassen für keine Änderung)" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="Passwort bestätigen"
|
||||
dependencies={['password']}
|
||||
rules={[
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const password = getFieldValue('password');
|
||||
if (!password && !value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (password && !value) {
|
||||
return Promise.reject(new Error('Bitte Passwort bestätigen'));
|
||||
}
|
||||
if (password && value && password !== value) {
|
||||
return Promise.reject(new Error('Passwörter stimmen nicht überein'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
})
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="Passwort wiederholen" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving} block>
|
||||
Benutzer speichern
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/UserMenu.js
Normal file
88
frontend/src/components/UserMenu.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Dropdown, Menu, Avatar, Button, Switch, message } from 'antd';
|
||||
import { UserOutlined, SettingOutlined, LogoutOutlined, ReloadOutlined, MoonOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
|
||||
export default function UserMenu({ user, onLogout, darkMode, onToggleDarkMode, sortMode, setSortMode, saving }) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
// Funktion zum Leeren aller ads-overview-Caches
|
||||
function clearAdsCache() {
|
||||
let removed = 0;
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('ads-overview-')) {
|
||||
localStorage.removeItem(key);
|
||||
removed++;
|
||||
}
|
||||
});
|
||||
if (removed > 0) {
|
||||
messageApi.success({ content: 'Cache erfolgreich geleert!', duration: 2 });
|
||||
} else {
|
||||
messageApi.info({ content: 'Kein Cache gefunden.', duration: 2 });
|
||||
}
|
||||
}
|
||||
const menu = {
|
||||
items: [
|
||||
{
|
||||
key: 'darkmode',
|
||||
icon: <MoonOutlined />,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span>Darkmode</span>
|
||||
<Switch checked={darkMode} onChange={onToggleDarkMode} size="small" />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
// Sortierfunktion als Menüpunkt
|
||||
{
|
||||
key: 'sortmode',
|
||||
icon: <RetweetOutlined />,
|
||||
label: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span>Sortieren</span>
|
||||
<Switch
|
||||
checked={!!sortMode}
|
||||
onChange={setSortMode}
|
||||
loading={!!saving}
|
||||
size="small"
|
||||
disabled={typeof sortMode === 'undefined' || typeof setSortMode !== 'function'}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
disabled: typeof sortMode === 'undefined' || typeof setSortMode !== 'function'
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'clearcache',
|
||||
icon: <ReloadOutlined />,
|
||||
label: 'Cache leeren',
|
||||
onClick: clearAdsCache,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Einstellungen (bald)',
|
||||
disabled: true,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Ausloggen',
|
||||
onClick: onLogout,
|
||||
},
|
||||
].filter(Boolean),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Dropdown menu={menu} placement="bottomRight">
|
||||
{/* <Button type="text" style={{ padding: 0, height: 40 }}> */}
|
||||
{/* {user.username || user.client} */}
|
||||
<Avatar style={{ backgroundColor: '#c792ff', color: '#001f1e', marginRight: 8 }} icon={<UserOutlined />} />
|
||||
{/* </Button> */}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
frontend/src/global.css
Normal file
17
frontend/src/global.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.ant-tabs {
|
||||
border-radius: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs > .ant-tabs-nav {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
z-index: 100;
|
||||
padding: 9px 32px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
padding: 0px 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
12
frontend/src/index.js
Normal file
12
frontend/src/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import 'antd/dist/reset.css';
|
||||
import './global.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
47
frontend/src/pages/AdminDashboard.js
Normal file
47
frontend/src/pages/AdminDashboard.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Layout } from 'antd';
|
||||
import UserManagement from '../components/UserManagement';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
import ClientsManagement from '../components/ClientsManagement';
|
||||
|
||||
export default function AdminDashboard({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||
return (
|
||||
<Layout>
|
||||
<Layout.Header style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: darkMode ? '#1f1f1f' : '#fff',
|
||||
padding: '0 32px'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Admin Dashboard</div>
|
||||
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ padding: 32 }}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={16}>
|
||||
<Card title="Benutzerverwaltung" variant="outlined">
|
||||
<UserManagement />
|
||||
</Card>
|
||||
<Card title="Clients" variant="outlined" style={{ marginTop: 16 }}>
|
||||
<ClientsManagement />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="Systeminfos" variant="outlined" disabled>
|
||||
Kommt bald…
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title="Einstellungen" variant="outlined" disabled>
|
||||
Kommt bald…
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
303
frontend/src/pages/ClientProjects.js
Normal file
303
frontend/src/pages/ClientProjects.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Card, Row, Col, Spin, Alert, Image, Layout, Switch, App } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getClientProjects } from '../services/projectService';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||
|
||||
export default function ClientProjects({ user, darkMode, onLogout, onToggleDarkMode }) {
|
||||
const { message } = App.useApp();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [sortMode, setSortMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const normalProjectsRef = useRef(null);
|
||||
const heProjectsRef = useRef(null);
|
||||
const normalSortableRef = useRef(null);
|
||||
const heSortableRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('jwt');
|
||||
getClientProjects(token).then(res => {
|
||||
if (res.success) {
|
||||
setProjects(res.projects);
|
||||
} else {
|
||||
setError(res.error?.message || 'Fehler beim Laden der Projekte');
|
||||
}
|
||||
}).catch(() => setError('Serverfehler')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// SortableJS initialisieren/zerstören
|
||||
useEffect(() => {
|
||||
if (sortMode && normalProjectsRef.current && heProjectsRef.current) {
|
||||
// Zerstöre bestehende Instanzen falls vorhanden
|
||||
if (normalSortableRef.current) {
|
||||
try {
|
||||
normalSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
normalSortableRef.current = null;
|
||||
}
|
||||
if (heSortableRef.current) {
|
||||
try {
|
||||
heSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
heSortableRef.current = null;
|
||||
}
|
||||
|
||||
// Initialisiere SortableJS für beide Bereiche
|
||||
normalSortableRef.current = Sortable.create(normalProjectsRef.current, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: handleSortEnd
|
||||
});
|
||||
|
||||
heSortableRef.current = Sortable.create(heProjectsRef.current, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: handleSortEnd
|
||||
});
|
||||
} else {
|
||||
// Zerstöre SortableJS Instanzen sicher
|
||||
if (normalSortableRef.current) {
|
||||
try {
|
||||
normalSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
normalSortableRef.current = null;
|
||||
}
|
||||
if (heSortableRef.current) {
|
||||
try {
|
||||
heSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
heSortableRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup beim Unmount
|
||||
if (normalSortableRef.current) {
|
||||
try {
|
||||
normalSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
normalSortableRef.current = null;
|
||||
}
|
||||
if (heSortableRef.current) {
|
||||
try {
|
||||
heSortableRef.current.destroy();
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
heSortableRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sortMode]);
|
||||
|
||||
const handleSortEnd = async (evt) => {
|
||||
// Neue Reihenfolge aus DOM extrahieren
|
||||
const newOrder = [];
|
||||
|
||||
// Normale Projekte
|
||||
if (normalProjectsRef.current) {
|
||||
const normalElements = normalProjectsRef.current.children;
|
||||
for (let el of normalElements) {
|
||||
const projectName = el.getAttribute('data-project-name');
|
||||
if (projectName) newOrder.push(projectName);
|
||||
}
|
||||
}
|
||||
|
||||
// HE Projekte
|
||||
if (heProjectsRef.current) {
|
||||
const heElements = heProjectsRef.current.children;
|
||||
for (let el of heElements) {
|
||||
const projectName = el.getAttribute('data-project-name');
|
||||
if (projectName) newOrder.push(projectName);
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere neue Reihenfolge
|
||||
await saveProjectOrder(newOrder);
|
||||
};
|
||||
|
||||
const saveProjectOrder = async (order) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const response = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(user?.dir)}/project-order`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ order })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
message.success('Reihenfolge gespeichert');
|
||||
// Projekte neu laden um die neue Reihenfolge zu übernehmen
|
||||
const token = localStorage.getItem('jwt');
|
||||
const res = await getClientProjects(token);
|
||||
if (res.success) {
|
||||
setProjects(res.projects);
|
||||
}
|
||||
} else {
|
||||
message.error('Fehler beim Speichern: ' + (result.error?.message || 'Unbekannter Fehler'));
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('Netzwerkfehler beim Speichern');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spin indicator={<LoadingOutlined spin />} size="large" tip="Lade Projekte..." fullscreen />;
|
||||
}
|
||||
if (error) {
|
||||
return <Alert type="error" message={error} showIcon />;
|
||||
}
|
||||
|
||||
// Projekte trennen
|
||||
const normalProjects = projects.filter(p => !p.isHE);
|
||||
const heProjects = projects.filter(p => p.isHE);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<style>{`
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.sortable-chosen {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.sortable-drag {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
.sortable-container .ant-col {
|
||||
cursor: ${sortMode ? 'grab' : 'default'};
|
||||
}
|
||||
.sortable-container .ant-col:active {
|
||||
cursor: ${sortMode ? 'grabbing' : 'default'};
|
||||
}
|
||||
`}</style>
|
||||
<Layout.Header style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: darkMode ? '#001f1e' : '#001f1e',
|
||||
color: darkMode ? '#fff' : '#fff',
|
||||
padding: '0 32px'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, fontSize: 20, color: darkMode ? '#fff' : undefined }}>Übersicht</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<UserMenu
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
darkMode={darkMode}
|
||||
onToggleDarkMode={onToggleDarkMode}
|
||||
sortMode={sortMode}
|
||||
setSortMode={setSortMode}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ padding: 32 }}>
|
||||
<Row gutter={32}>
|
||||
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
|
||||
<h3>Cinema</h3>
|
||||
<Row
|
||||
ref={normalProjectsRef}
|
||||
gutter={[16, 4]}
|
||||
style={{ columnGap: 8, marginLeft: 0 }}
|
||||
className={sortMode ? 'sortable-container' : ''}
|
||||
>
|
||||
{normalProjects.length === 0 && <Col><Alert type="info" message="Keine Cinema Projekte gefunden." showIcon /></Col>}
|
||||
{normalProjects.map((p, i) => {
|
||||
return (
|
||||
<Col
|
||||
key={p.name}
|
||||
data-project-name={p.name}
|
||||
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
|
||||
>
|
||||
{p.poster && (
|
||||
<Link
|
||||
to={`/${encodeURIComponent(p.name)}`}
|
||||
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
|
||||
>
|
||||
<Image
|
||||
src={backendUrl + p.poster}
|
||||
alt="Poster"
|
||||
width={120}
|
||||
height={178}
|
||||
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
|
||||
preview={false}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Col>
|
||||
<Col xs={24} md={12} style={{ marginBottom: 32 }}>
|
||||
<h3>Home Entertainment</h3>
|
||||
<Row
|
||||
ref={heProjectsRef}
|
||||
gutter={[16, 4]}
|
||||
style={{ columnGap: 8, marginLeft: 0 }}
|
||||
className={sortMode ? 'sortable-container' : ''}
|
||||
>
|
||||
{heProjects.length === 0 && <Col><Alert type="info" message="Keine Home Entertainment Projekte gefunden." showIcon /></Col>}
|
||||
{heProjects.map((p, i) => {
|
||||
return (
|
||||
<Col
|
||||
key={p.name}
|
||||
data-project-name={p.name}
|
||||
style={{ padding: 0, margin: 0, width: 120, maxWidth: 120 }}
|
||||
>
|
||||
{p.poster && (
|
||||
<Link
|
||||
to={`/${encodeURIComponent(p.name)}`}
|
||||
style={{ display: 'block', pointerEvents: sortMode ? 'none' : 'auto' }}
|
||||
>
|
||||
<Image
|
||||
src={backendUrl + p.poster}
|
||||
alt="Poster"
|
||||
width={120}
|
||||
height={178}
|
||||
style={{ objectFit: 'cover', display: 'block', width: 120, height: 178, borderRadius: 4, cursor: sortMode ? 'grab' : 'pointer' }}
|
||||
preview={false}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
78
frontend/src/pages/LoginPage.js
Normal file
78
frontend/src/pages/LoginPage.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Typography, Alert, message } from 'antd';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const darkMode = typeof window !== 'undefined' && localStorage.getItem('darkMode') === 'true';
|
||||
|
||||
const onFinish = async (values) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('jwt', data.token);
|
||||
message.success('Login erfolgreich!');
|
||||
setTimeout(() => {
|
||||
window.location.href = window.location.pathname + window.location.search || '/';
|
||||
}, 600);
|
||||
onLogin && onLogin(data);
|
||||
} else {
|
||||
setError(data.error?.message || 'Login fehlgeschlagen');
|
||||
}
|
||||
} catch (e) {
|
||||
setError('Serverfehler');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
width: '100vw',
|
||||
background: darkMode ? '#181818' : '#f5f5f5',
|
||||
transition: 'background 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 350,
|
||||
padding: 24,
|
||||
background: darkMode ? '#1f1f1f' : '#fff',
|
||||
borderRadius: 8,
|
||||
boxShadow: darkMode ? '0 2px 8px #222' : '0 2px 8px #eee',
|
||||
color: darkMode ? '#fff' : undefined
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ textAlign: 'center', color: darkMode ? '#fff' : undefined }}>Anmeldung</Title>
|
||||
{error && <Alert type="error" message={error} showIcon style={{ marginBottom: 16 }} />}
|
||||
<Form name="login" onFinish={onFinish} layout="vertical">
|
||||
<Form.Item name="username" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Benutzername (nur Admin)</span>} >
|
||||
<Input prefix={<UserOutlined />} placeholder="Benutzername" autoComplete="username" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label={<span style={{ color: darkMode ? '#fff' : undefined }}>Passwort</span>} rules={[{ required: true, message: 'Bitte Passwort eingeben!' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="Passwort" autoComplete="current-password" style={darkMode ? { background: '#222', color: '#fff' } : {}} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
Einloggen
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/pages/ProjectDetail.js
Normal file
320
frontend/src/pages/ProjectDetail.js
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Tabs, Button, Spin, Skeleton, Result } from 'antd';
|
||||
import { ArrowLeftOutlined, LoadingOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import UserMenu from '../components/UserMenu';
|
||||
import FilePreview, { formatFileSize, formatDuration } from '../components/FilePreview';
|
||||
import debugLogger from '../utils/debugLogger';
|
||||
|
||||
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||
|
||||
function ProjectDetail({ user, darkMode, onLogout, onToggleDarkMode, overrideParams, ...props }) {
|
||||
// URL-Parameter holen, mit Override-Support für Smart Resolution
|
||||
const routeParams = useParams();
|
||||
const params = overrideParams || routeParams;
|
||||
const { client: routeClient, project, tab } = params;
|
||||
const client = user.role === 'admin' ? routeClient : user.client;
|
||||
const navigate = useNavigate();
|
||||
|
||||
debugLogger.routing('ProjectDetail - Received params:', {
|
||||
routeParams,
|
||||
overrideParams,
|
||||
finalParams: params,
|
||||
extractedValues: { client, project, tab }
|
||||
});
|
||||
|
||||
// State für Fehlerstatus
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Hole Projekt-Logo (und ggf. weitere Metadaten)
|
||||
const [projectLogo, setProjectLogo] = useState(null);
|
||||
|
||||
// Zoom-Logik (muss im Component-Scope stehen, nicht in useEffect!)
|
||||
const ZOOM_LEVELS = [0.15, 0.25, 0.5, 0.75, 1];
|
||||
const [zoom, setZoom] = useState(() => {
|
||||
const z = parseFloat(localStorage.getItem('adsZoom') || '1');
|
||||
return ZOOM_LEVELS.includes(z) ? z : 1;
|
||||
});
|
||||
function handleZoom(dir) {
|
||||
setZoom(z => {
|
||||
const idx = ZOOM_LEVELS.indexOf(z);
|
||||
let next = z;
|
||||
if (dir === 'in') next = ZOOM_LEVELS[Math.min(ZOOM_LEVELS.length - 1, idx + 1)];
|
||||
if (dir === 'out') next = ZOOM_LEVELS[Math.max(0, idx - 1)];
|
||||
localStorage.setItem('adsZoom', next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLogo() {
|
||||
if (!client || !project) {
|
||||
setProjectLogo(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data && data.success) {
|
||||
const { logo } = data.data;
|
||||
if (logo) setProjectLogo(logo);
|
||||
}
|
||||
} catch (e) {
|
||||
setProjectLogo(null);
|
||||
}
|
||||
}
|
||||
fetchLogo();
|
||||
}, [client, project]);
|
||||
|
||||
// Tabs-Logik: Kategorien aus API laden
|
||||
const [tabsData, setTabsData] = useState(null);
|
||||
const [activeKey, setActiveKey] = useState(tab);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Tabs mit useMemo erstellen, damit sie sich bei Zoom-Änderung aktualisieren ohne API-Call
|
||||
const tabs = React.useMemo(() => {
|
||||
if (!tabsData) return [];
|
||||
return buildTabsFromCategories(tabsData);
|
||||
}, [tabsData, zoom]);
|
||||
|
||||
function buildTabsFromCategories(data) {
|
||||
let categories = [];
|
||||
if (Array.isArray(data)) {
|
||||
categories = data.filter(child => child.type === 'category');
|
||||
} else if (data && Array.isArray(data.children)) {
|
||||
categories = data.children.filter(child => child.type === 'category');
|
||||
}
|
||||
return categories.map(cat => ({
|
||||
key: cat.title,
|
||||
label: cat.title,
|
||||
children: (
|
||||
<div>
|
||||
{Array.isArray(cat.children) && cat.children.filter(sub => sub.type === 'subcategory').length > 0 ? (
|
||||
cat.children.filter(sub => sub.type === 'subcategory').map(sub => (
|
||||
<div key={sub.name} style={{ marginTop: 24 }}>
|
||||
<h2 style={{ marginBottom: 4 }}>{sub.name}</h2>
|
||||
{/* Ads unterhalb der Subcategory anzeigen */}
|
||||
{Array.isArray(sub.children) && sub.children.filter(ad => ad.type === 'ad').length > 0 ? (
|
||||
sub.children.filter(ad => ad.type === 'ad').map(ad => (
|
||||
<div key={ad.name} style={{ marginLeft: 0, marginBottom: 16 }}>
|
||||
<div style={{ color: '#555', fontSize: 15, display: 'flex', alignItems: 'center', gap: 8 }}>{ad.name}</div>
|
||||
{/* Dateien unterhalb des Ads anzeigen */}
|
||||
{Array.isArray(ad.files) && ad.files.length > 0 ? (
|
||||
<div style={{ marginLeft: 0, display: 'flex', flexWrap: 'wrap', gap: 32 }}>
|
||||
{ad.files.map(file => (
|
||||
<FilePreview key={file.name + '__' + zoom} file={file} zoom={zoom} darkMode={darkMode} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#bbb', marginLeft: 16, fontSize: 13 }}>Keine Dateien vorhanden.</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: '#aaa', marginLeft: 16, fontSize: 14 }}>Keine Ads vorhanden.</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ color: '#888', marginTop: 16 }}>Keine Subkategorien vorhanden.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Wenn Routing-Parameter fehlen, Tabs zurücksetzen
|
||||
if (!client || !project) {
|
||||
setTabsData(null);
|
||||
setActiveKey(undefined);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const cacheKey = `ads-overview-${client}-${project}`;
|
||||
const cacheTTL = 10 * 60 * 1000; // 10 Minuten
|
||||
async function fetchTabs(forceApi = false) {
|
||||
setLoading(true);
|
||||
// Prüfe Cache
|
||||
if (!forceApi) {
|
||||
const cached = localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached);
|
||||
if (parsed && parsed.data && parsed.timestamp && Date.now() - parsed.timestamp < cacheTTL) {
|
||||
// Gültiger Cache
|
||||
const data = parsed.data;
|
||||
setTabsData(data);
|
||||
const tabsArr = buildTabsFromCategories(data);
|
||||
if (tabsArr.length > 0) {
|
||||
if (tab && tabsArr.some(t => t.key === tab)) {
|
||||
setActiveKey(tab);
|
||||
} else {
|
||||
setActiveKey(tabsArr[0].key);
|
||||
// Wenn Tab in URL fehlt oder ungültig, URL anpassen
|
||||
if (!tab || !tabsArr.some(t => t.key === tab)) {
|
||||
if (user.role === 'admin') {
|
||||
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||
} else {
|
||||
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fehler beim Parsen, ignoriere Cache und hole neu
|
||||
}
|
||||
}
|
||||
}
|
||||
// Kein Cache, abgelaufen oder Fehler: Hole neu
|
||||
try {
|
||||
setError(null); // Reset error state
|
||||
const token = localStorage.getItem('jwt');
|
||||
const res = await fetch(`${backendUrl}/api/projects/${encodeURIComponent(client)}/${encodeURIComponent(project)}/ads-overview`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// HTTP-Fehler behandeln
|
||||
if (res.status === 403) {
|
||||
setError({ type: 'forbidden', message: 'Keine Zugriffsberechtigung auf dieses Projekt' });
|
||||
} else if (res.status === 404) {
|
||||
setError({ type: 'notFound', message: 'Projekt wurde nicht gefunden' });
|
||||
} else {
|
||||
setError({ type: 'general', message: `Fehler beim Laden der Daten (${res.status})` });
|
||||
}
|
||||
setTabsData(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
// Speichere im Cache
|
||||
localStorage.setItem(cacheKey, JSON.stringify({ data, timestamp: Date.now() }));
|
||||
setTabsData(data);
|
||||
const tabsArr = buildTabsFromCategories(data);
|
||||
if (tabsArr.length > 0) {
|
||||
if (tab && tabsArr.some(t => t.key === tab)) {
|
||||
setActiveKey(tab);
|
||||
} else {
|
||||
setActiveKey(tabsArr[0].key);
|
||||
if (!tab || !tabsArr.some(t => t.key === tab)) {
|
||||
if (user.role === 'admin') {
|
||||
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||
} else {
|
||||
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(tabsArr[0].key)}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError({ type: 'network', message: 'Netzwerkfehler beim Laden der Daten' });
|
||||
setTabsData(null);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
fetchTabs();
|
||||
}, [user, client, project, tab]);
|
||||
|
||||
// Tab-Wechsel: Schreibe Tab in die URL
|
||||
const handleTabChange = (key) => {
|
||||
setActiveKey(key);
|
||||
// Clients verwenden /:project/:tab, Admins /:client/:project/:tab
|
||||
if (user.role === 'admin') {
|
||||
navigate(`/${encodeURIComponent(client)}/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
|
||||
} else {
|
||||
navigate(`/${encodeURIComponent(project)}/${encodeURIComponent(key)}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{`
|
||||
.ads-details-tabs .ant-tabs-nav {
|
||||
background: ${darkMode ? '#1f1f1f' : '#001f1e'};
|
||||
color: ${darkMode ? '#fff' : '#fff'};
|
||||
transition: background 0.2s;
|
||||
}
|
||||
`}</style>
|
||||
{/* Zoom-Buttons jetzt in tabBarExtraContent.right */}
|
||||
<Spin spinning={loading} indicator={<LoadingOutlined spin />} size="large" tip="Lade Projektdaten..." fullscreen />
|
||||
{!loading && (
|
||||
tabs.length > 0 ? (
|
||||
<Tabs
|
||||
className="ads-details-tabs"
|
||||
items={tabs}
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
tabBarExtraContent={{
|
||||
left: (
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/')}
|
||||
style={{ marginRight: projectLogo ? 12 : 0 }}
|
||||
/>
|
||||
{projectLogo && (
|
||||
<img
|
||||
src={projectLogo}
|
||||
alt="Logo"
|
||||
style={{ height: 38, marginRight: 32, objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
right: (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Button size="small" onClick={() => handleZoom('out')} disabled={zoom <= ZOOM_LEVELS[0]}>-</Button>
|
||||
<span style={{ minWidth: 48, textAlign: 'center' }}>{Math.round(zoom * 100)}%</span>
|
||||
<Button size="small" onClick={() => handleZoom('in')} disabled={zoom >= ZOOM_LEVELS[ZOOM_LEVELS.length - 1]}>+</Button>
|
||||
</div>
|
||||
<UserMenu user={user} onLogout={onLogout} darkMode={darkMode} onToggleDarkMode={onToggleDarkMode} />
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : error ? (
|
||||
// Spezifische Fehlerbehandlung
|
||||
<Result
|
||||
icon={error.type === 'forbidden' ? <LockOutlined /> : undefined}
|
||||
status={error.type === 'forbidden' ? '403' : error.type === 'notFound' ? '404' : 'error'}
|
||||
title={
|
||||
error.type === 'forbidden' ? 'Keine Zugriffsberechtigung' :
|
||||
error.type === 'notFound' ? 'Projekt nicht gefunden' :
|
||||
'Fehler beim Laden'
|
||||
}
|
||||
subTitle={error.message}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/')}>
|
||||
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
// Fallback für andere Fälle ohne spezifischen Fehler
|
||||
<div style={{ padding: 32, textAlign: 'center', color: '#888' }}>
|
||||
<div>Keine Daten gefunden.</div>
|
||||
<Button type="primary" style={{ marginTop: 24 }} onClick={() => navigate('/')}>
|
||||
{user.role === 'admin' ? 'Zurück zum Dashboard' : 'Zurück zur Übersicht'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default ProjectDetail;
|
||||
7
frontend/src/services/api.js
Normal file
7
frontend/src/services/api.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export async function getCurrentUser() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const res = await fetch('/api/auth/user', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
42
frontend/src/services/entityService.js
Normal file
42
frontend/src/services/entityService.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Service für generische CRUD-Operationen auf Admin-Ressourcen (users, clients)
|
||||
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||
|
||||
function authHeaders(contentType = false) {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const headers = { 'Authorization': 'Bearer ' + token };
|
||||
if (contentType) headers['Content-Type'] = 'application/json';
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function getAll(type) {
|
||||
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
|
||||
headers: authHeaders()
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function create(type, data) {
|
||||
const res = await fetch(`${backendUrl}/api/admin/${type}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function update(type, id, data) {
|
||||
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(true),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function remove(type, id) {
|
||||
const res = await fetch(`${backendUrl}/api/admin/${type}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders()
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
6
frontend/src/services/projectService.js
Normal file
6
frontend/src/services/projectService.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export async function getClientProjects(token) {
|
||||
const res = await fetch('/api/projects', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
147
frontend/src/services/urlResolutionService.js
Normal file
147
frontend/src/services/urlResolutionService.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// Smart URL Resolution Service für Admin-Zugriff auf Client-URLs
|
||||
import debugLogger from '../utils/debugLogger';
|
||||
|
||||
class URLResolutionService {
|
||||
constructor() {
|
||||
// Backend-URL mit Fallback auf localhost:8000
|
||||
this.baseUrl = process.env.REACT_APP_BACKEND || 'http://localhost:8000';
|
||||
this.cache = new Map();
|
||||
this.cacheExpiry = 5 * 60 * 1000; // 5 Minuten
|
||||
}
|
||||
|
||||
// Projekt-Client-Mapping vom Backend laden
|
||||
async fetchProjectClientMapping() {
|
||||
debugLogger.api('Fetching project-client mapping from backend...');
|
||||
try {
|
||||
const token = localStorage.getItem('jwt');
|
||||
if (!token) {
|
||||
debugLogger.error('Kein JWT Token verfügbar');
|
||||
throw new Error('Kein Token verfügbar');
|
||||
}
|
||||
|
||||
debugLogger.api('API Request to:', `${this.baseUrl}/api/admin/project-client-mapping`);
|
||||
const response = await fetch(`${this.baseUrl}/api/admin/project-client-mapping`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
debugLogger.api('API Response Status:', response.status);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
debugLogger.error('Backend Error Response:', errorText);
|
||||
throw new Error(`Backend Fehler: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
debugLogger.api('Backend Response Data:', data);
|
||||
|
||||
if (!data.success) {
|
||||
debugLogger.error('Backend returned success=false:', data.message);
|
||||
throw new Error(data.message || 'Unbekannter Backend-Fehler');
|
||||
}
|
||||
|
||||
debugLogger.success('Mapping successfully loaded:', data.mapping);
|
||||
return data.mapping || {};
|
||||
} catch (error) {
|
||||
debugLogger.error('Fehler beim Laden des Projekt-Client-Mappings:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Gecachtes Mapping abrufen oder neu laden
|
||||
async getProjectClientMapping() {
|
||||
const cached = this.cache.get('projectClientMapping');
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheExpiry) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Frische Daten laden und cachen
|
||||
const freshData = await this.fetchProjectClientMapping();
|
||||
this.cache.set('projectClientMapping', {
|
||||
data: freshData,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
return freshData;
|
||||
}
|
||||
|
||||
// Smart URL Resolution: Finde Client für ein Projekt
|
||||
async resolveClientForProject(projectName) {
|
||||
debugLogger.routing('Smart URL Resolution für Projekt:', projectName);
|
||||
|
||||
if (!projectName) {
|
||||
debugLogger.routing('Kein Projektname angegeben');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prüfe ob Admin (nur Admins dürfen URL Resolution verwenden)
|
||||
if (!this.isAdmin()) {
|
||||
debugLogger.routing('User ist kein Admin');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
debugLogger.api('Lade Projekt-Client-Mapping...');
|
||||
const mapping = await this.getProjectClientMapping();
|
||||
debugLogger.api('Erhaltenes Mapping:', mapping);
|
||||
|
||||
const clientName = mapping[projectName] || null;
|
||||
debugLogger.success(`Projekt "${projectName}" → Client "${clientName}"`);
|
||||
|
||||
return clientName;
|
||||
} catch (error) {
|
||||
debugLogger.error('Fehler bei URL Resolution:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfe ob User Admin ist
|
||||
isAdmin() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
debugLogger.auth('JWT Token check:', token ? 'Token vorhanden' : 'Kein Token');
|
||||
|
||||
if (!token) return false;
|
||||
|
||||
try {
|
||||
// JWT-Token parsen (vereinfacht)
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
debugLogger.auth('Token Payload:', payload);
|
||||
const isAdminUser = payload.role === 'admin';
|
||||
debugLogger.auth('Is Admin:', isAdminUser);
|
||||
return isAdminUser;
|
||||
} catch (error) {
|
||||
debugLogger.error('Token parsing error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart URL Resolution für Admin-Zugriff
|
||||
async resolveAdminUrl(projectName, tab = null) {
|
||||
if (!this.isAdmin()) {
|
||||
return null; // Nur für Admins
|
||||
}
|
||||
|
||||
const clientName = await this.resolveClientForProject(projectName);
|
||||
if (!clientName) {
|
||||
return null; // Projekt nicht gefunden
|
||||
}
|
||||
|
||||
// Konstruiere Admin-URL
|
||||
if (tab) {
|
||||
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}/${encodeURIComponent(tab)}`;
|
||||
} else {
|
||||
return `/${encodeURIComponent(clientName)}/${encodeURIComponent(projectName)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache leeren (z.B. bei Logout)
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
const urlResolutionService = new URLResolutionService();
|
||||
export default urlResolutionService;
|
||||
14
frontend/src/services/userService.js
Normal file
14
frontend/src/services/userService.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Service für Benutzer-API
|
||||
const backendUrl = process.env.REACT_APP_BACKEND || '';
|
||||
|
||||
export async function getAllUsers() {
|
||||
const token = localStorage.getItem('jwt');
|
||||
const res = await fetch(`${backendUrl}/api/admin/users`, {
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token
|
||||
}
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Weitere Funktionen wie createUser, updateUser, deleteUser können später ergänzt werden.
|
||||
77
frontend/src/utils/debugLogger.example.js
Normal file
77
frontend/src/utils/debugLogger.example.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Beispiel für die Verwendung des globalen Debug-Loggers
|
||||
* Kann als Referenz für andere Komponenten dienen
|
||||
*/
|
||||
|
||||
import debugLogger from '../utils/debugLogger';
|
||||
|
||||
// Beispiel: Verwendung in einer API-Service
|
||||
class ExampleService {
|
||||
async fetchData() {
|
||||
debugLogger.api('Fetching data from API...');
|
||||
|
||||
try {
|
||||
debugLogger.time('API Request');
|
||||
const response = await fetch('/api/data');
|
||||
debugLogger.timeEnd('API Request');
|
||||
|
||||
if (!response.ok) {
|
||||
debugLogger.error('API request failed:', response.status);
|
||||
throw new Error('API Error');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
debugLogger.success('Data fetched successfully:', data);
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
debugLogger.error('Error fetching data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beispiel: Verwendung in einer React-Komponente
|
||||
function ExampleComponent() {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
debugLogger.group('ExampleComponent Mount');
|
||||
debugLogger.routing('Component mounted with props:', { data });
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const result = await new ExampleService().fetchData();
|
||||
setData(result);
|
||||
debugLogger.success('Component state updated');
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load component data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
debugLogger.groupEnd();
|
||||
}, []);
|
||||
|
||||
// Authentifizierung prüfen
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('jwt');
|
||||
debugLogger.auth('Checking authentication status');
|
||||
|
||||
if (!token) {
|
||||
debugLogger.warn('No authentication token found');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugLogger.auth('Authentication token present');
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Component JSX */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ExampleService, ExampleComponent };
|
||||
121
frontend/src/utils/debugLogger.js
Normal file
121
frontend/src/utils/debugLogger.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Globale Debug-Utility für die gesamte Anwendung
|
||||
* Konfigurierbar über .env Variablen
|
||||
*/
|
||||
|
||||
class DebugLogger {
|
||||
constructor() {
|
||||
// Debug-Konfiguration aus .env Datei
|
||||
this.isEnabled = process.env.REACT_APP_DEBUG_ENABLED === 'true';
|
||||
this.apiDebug = process.env.REACT_APP_DEBUG_API === 'true';
|
||||
this.routingDebug = process.env.REACT_APP_DEBUG_ROUTING === 'true';
|
||||
this.authDebug = process.env.REACT_APP_DEBUG_AUTH === 'true';
|
||||
|
||||
// Fallback: In Development-Umgebung standardmäßig aktiviert
|
||||
if (process.env.NODE_ENV === 'development' && this.isEnabled === undefined) {
|
||||
this.isEnabled = true;
|
||||
this.apiDebug = true;
|
||||
this.routingDebug = true;
|
||||
this.authDebug = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Allgemeine Debug-Logs
|
||||
log(message, ...args) {
|
||||
if (this.isEnabled) {
|
||||
console.log(`🔧 [DEBUG]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// API-bezogene Debug-Logs
|
||||
api(message, ...args) {
|
||||
if (this.isEnabled && this.apiDebug) {
|
||||
console.log(`📡 [API]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Routing-bezogene Debug-Logs
|
||||
routing(message, ...args) {
|
||||
if (this.isEnabled && this.routingDebug) {
|
||||
console.log(`🚀 [ROUTING]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Authentifizierungs-bezogene Debug-Logs
|
||||
auth(message, ...args) {
|
||||
if (this.isEnabled && this.authDebug) {
|
||||
console.log(`🔐 [AUTH]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Error-Logs (immer aktiviert, auch in Production für kritische Fehler)
|
||||
error(message, ...args) {
|
||||
console.error(`❌ [ERROR]`, message, ...args);
|
||||
}
|
||||
|
||||
// Warning-Logs (immer aktiviert)
|
||||
warn(message, ...args) {
|
||||
console.warn(`⚠️ [WARN]`, message, ...args);
|
||||
}
|
||||
|
||||
// Success-Logs (nur wenn Debug aktiviert)
|
||||
success(message, ...args) {
|
||||
if (this.isEnabled) {
|
||||
console.log(`✅ [SUCCESS]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Info-Logs (nur wenn Debug aktiviert)
|
||||
info(message, ...args) {
|
||||
if (this.isEnabled) {
|
||||
console.info(`ℹ️ [INFO]`, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// Gruppen für zusammenhängende Logs
|
||||
group(title) {
|
||||
if (this.isEnabled) {
|
||||
console.group(`🔧 [DEBUG GROUP] ${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
groupEnd() {
|
||||
if (this.isEnabled) {
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
// Zeitmessung für Performance-Debugging
|
||||
time(label) {
|
||||
if (this.isEnabled) {
|
||||
console.time(`⏱️ [TIMER] ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
timeEnd(label) {
|
||||
if (this.isEnabled) {
|
||||
console.timeEnd(`⏱️ [TIMER] ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Debug-Status anzeigen
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.isEnabled,
|
||||
api: this.apiDebug,
|
||||
routing: this.routingDebug,
|
||||
auth: this.authDebug,
|
||||
environment: process.env.NODE_ENV
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
const debugLogger = new DebugLogger();
|
||||
|
||||
// Zeige Debug-Status beim Import
|
||||
if (debugLogger.isEnabled) {
|
||||
debugLogger.info('Debug Logger initialized:', debugLogger.getStatus());
|
||||
}
|
||||
|
||||
export default debugLogger;
|
||||
Reference in New Issue
Block a user