DEV1.0: Initial commit - Workflow Portal with security fixes
- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration - Frontend: React 18 + Vite + TailwindCSS/DaisyUI - Security fixes applied (2026-07 + 2026-08): - LDAP injection prevention, CSRF protection, HttpOnly cookies - Session hashing (SHA-256), account lockout, rate limiting - Input validation (zod), file upload security, CSP/HSTS headers - V3: express-rate-limit updated (ip-address SSRF fix) - V4: postcss updated (nanoid DoS fix) - V5: Rate-limit on /health endpoint - V6: Session rotation on login (session fixation prevention) - V9: Task values array limit (DoS prevention) - V10: Frontend XSS audit completed - Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
259
frontend/src/pages/Dashboard.jsx
Normal file
259
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import FillModal from '../components/FillModal';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
export default function Dashboard({ onNavigate }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fillTemplate, setFillTemplate] = useState(null);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!isAdmin) return;
|
||||
try {
|
||||
const res = await apiFetch('/stats');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Statistiken:', e);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchTemplates, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTemplates, fetchStats]);
|
||||
|
||||
// Alle Vorlagen sind für jeden User sichtbar (nicht mehr nach is_assignable gefiltert)
|
||||
const topTemplates = stats?.topTemplates || [];
|
||||
const filtered = templates.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(t.description || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSubmitTask = async (payload) => {
|
||||
const res = await apiFetch('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, user_id: user.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Fehler beim Absenden');
|
||||
}
|
||||
setFillTemplate(null);
|
||||
toast.success('Aufgabe erfolgreich abgesendet!');
|
||||
fetchStats();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
</div>
|
||||
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
{/* KPI-Sektion: 4 Statistik-Karten mit Delta + Fortschrittsbalken */}
|
||||
{isAdmin && stats && (
|
||||
<section aria-label="Kennzahlen" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
{/* StatCard: Offene Aufgaben */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Offene Aufgaben</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.openTasks}</span>
|
||||
{stats.totalTasks > 0 && (
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{Math.round((stats.openTasks / stats.totalTasks) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.openTasks / stats.totalTasks) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: Abgeschlossen */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Abgeschlossen</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.completedTasks}</span>
|
||||
{stats.totalTasks > 0 && (
|
||||
<span className="text-xs font-medium text-success">
|
||||
{Math.round((stats.completedTasks / stats.totalTasks) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-success transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.completedTasks / stats.totalTasks) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: Aktive Nutzer */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Aktive Nutzer</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.activeUsers}</span>
|
||||
{stats.totalUsers > 0 && (
|
||||
<span className="text-xs font-medium text-secondary">
|
||||
von {stats.totalUsers}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-secondary transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.activeUsers / stats.totalUsers) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: AD-Nutzer */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">AD-Nutzer</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.adUsers}</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.adUsers / stats.totalUsers) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Linke Spalte: Top-Vorlagen Rangliste */}
|
||||
{isAdmin && topTemplates.length > 0 && (
|
||||
<section className="lg:col-span-4">
|
||||
<div className="flex items-center justify-between mb-4 h-[38px]">
|
||||
<h2 className="text-xl font-medium tracking-tight">Meistgenutzte Vorlagen</h2>
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{topTemplates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="p-4 flex items-center justify-between hover:bg-base-300/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{t.name}</span>
|
||||
<span className="text-xs opacity-60">{t.task_count} Aufgaben</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-mono py-1 px-2 rounded shrink-0 ml-3 ${
|
||||
t.rank === 1 || t === topTemplates[0]
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'bg-base-300 text-base-content/60'
|
||||
}`}>
|
||||
TOP {topTemplates.indexOf(t) + 1}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */}
|
||||
<section className={isAdmin && topTemplates.length > 0 ? "lg:col-span-8" : "lg:col-span-12"}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 h-[38px]">
|
||||
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
|
||||
<div className="form-control w-full sm:w-72">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vorlagen durchsuchen..."
|
||||
className="input input-bordered w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
||||
<p className="text-sm opacity-60">Keine Vorlagen vorhanden.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
||||
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{filtered.map((tpl) => {
|
||||
const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length;
|
||||
return (
|
||||
<article key={tpl.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-all rounded-lg p-4 flex flex-col justify-between min-h-32">
|
||||
<div>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<span className="text-[10px] opacity-50 font-mono">{pageCount} Seiten</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-base font-medium line-clamp-2 leading-tight">{tpl.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setFillTemplate(tpl)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{fillTemplate && (
|
||||
<FillModal
|
||||
template={fillTemplate}
|
||||
onSubmit={handleSubmitTask}
|
||||
onClose={() => setFillTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user