Security & UX Release v7

Security:
- H1: Stored-XSS-Fix — Upload-Pfad-Whitelist (Server + Frontend-Guard safeFileUrl)
- H2: Transaktionen repariert — txDb-Contract in db.js (PG + SQLite), Rollback funktioniert
- H3-Vorbereitung: SESSION_SECRET wird in Compose durchgereicht (Fix M3)
- registerLimiter exportiert (Crash-Bug: Route.post ohne Callback)
- LDAP-Sync: PG-Transaktionsabbruch bei UNIQUE-Verstoß behoben (Precheck-Selects)
- LDAP-Filter: nur echte Benutzerkonten (keine Computer/Service-Accounts, Bit 512)
- Rollen app-seitig: Sync ändert nie role/status, neue User immer user+inaktiv
- DB-Cleanup: 82 Computer-/Service-Accounts aus lokaler User-Tabelle entfernt

UX:
- Dashboard: Vorlagen als Table-Liste + Column-Chart (Top 5 in %), 2 gleich große Spalten
- Table-Listen (Dashboard/Vorlageneditor/Aufgaben) scrollbar bis Seitenende
- Pagination 10/Seite im Dashboard, Sidebar-Label Dashboard
This commit is contained in:
Kühn
2026-09-10 16:57:20 +02:00
parent ec2ed91621
commit 1aec65dc95
86 changed files with 636 additions and 208 deletions

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import FillModal from '../components/FillModal';
import TemplateCard from '../components/TemplateCard';
import { apiFetch } from '../utils/api';
export default function Dashboard({ onNavigate }) {
@@ -13,6 +14,9 @@ export default function Dashboard({ onNavigate }) {
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [fillTemplate, setFillTemplate] = useState(null);
// Pagination für die Vorlagen-Table-Liste (10 Einträge pro Seite)
const TEMPLATES_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
const fetchTemplates = useCallback(async () => {
try {
@@ -52,6 +56,12 @@ export default function Dashboard({ onNavigate }) {
t.name.toLowerCase().includes(search.toLowerCase()) ||
(t.description || '').toLowerCase().includes(search.toLowerCase())
);
// Pagination: Suchwechsel setzt auf Seite 1 zurück
const totalPages = Math.max(Math.ceil(filtered.length / TEMPLATES_PER_PAGE), 1);
const pagedTemplates = filtered.slice((currentPage - 1) * TEMPLATES_PER_PAGE, currentPage * TEMPLATES_PER_PAGE);
useEffect(() => {
setCurrentPage(1);
}, [search]);
const handleSubmitTask = async (payload) => {
const res = await apiFetch('/tasks', {
@@ -160,42 +170,11 @@ export default function Dashboard({ onNavigate }) {
</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]">
{/* Haupt-Grid: Vorlagen-Liste (links) | Graph-Card (rechts) — gleich große Spalten */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-stretch">
{/* Linke Spalte: Vorlagen als Table-Liste */}
<section aria-label="Vorlagen" className="flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
<div className="form-control w-full sm:w-72">
<input
@@ -217,34 +196,96 @@ export default function Dashboard({ onNavigate }) {
<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>
<>
{/* Scrollbare Liste — flex-1 + min-h-0 (Pflicht für overflow in Flexbox),
damit die Liste innerhalb der Card-Höhe scrollt statt sie aufzuspannen */}
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 flex flex-col min-h-[600px] max-h-[600px]">
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
{pagedTemplates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
action={
<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>
</div>
{/* Pagination: 10 Vorlagen pro Seite */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<button
className="btn btn-sm btn-outline"
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
>
« Zurück
</button>
<span className="text-sm opacity-70">
Seite {currentPage} von {totalPages}
</span>
<button
className="btn btn-sm btn-outline"
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
>
Weiter »
</button>
</div>
)}
</>
)}
</section>
{/* Rechte Spalte: Meistgenutzte Vorlagen als Column-Chart (Anteil in %) */}
{isAdmin && topTemplates.length > 0 && (
<section aria-label="Meistgenutzte Vorlagen" className="flex flex-col">
{/* Header mit gleicher Mindesthöhe wie der Listen-Header (Suchbox = 48px),
damit beide Cards auf derselben Höhe starten */}
<div className="flex items-center justify-between mb-4 sm:min-h-[48px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzt</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl flex-1 flex flex-col">
<div className="card-body p-5 flex-1 flex flex-col justify-center">
{(() => {
// Prozentanteil jeder Vorlage an allen Task-Zuordnungen der Top-Liste
const totalCount = topTemplates.reduce((sum, t) => sum + (t.task_count || 0), 0);
if (totalCount === 0) return null;
return (
<div className="flex items-end justify-around gap-2 sm:gap-4 h-64 pt-2">
{topTemplates.map((t, idx) => {
const pct = Math.round(((t.task_count || 0) / totalCount) * 100);
return (
<div key={t.id} className="flex flex-col items-center justify-end h-full flex-1 min-w-0">
{/* Prozentwert über der Säule */}
<span className="text-xs font-semibold tabular-nums mb-1">{pct}%</span>
{/* Säule: Höhe proportional zum Prozentanteil, animiert per CSS-Transition */}
<div
className={`w-full max-w-16 rounded-t-md transition-all duration-700 ${idx === 0 ? 'bg-primary' : 'bg-primary/50'}`}
style={{ height: `${Math.max(pct, 2)}%` }}
/>
{/* Beschriftung unter der Säule */}
<span className="text-[11px] opacity-70 truncate w-full text-center mt-2" title={t.name}>
{t.name}
</span>
</div>
);
})}
</div>
);
})()}
</div>
</div>
</section>
)}
</div>
{fillTemplate && (