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:
Kühn
2026-08-24 09:45:28 +02:00
commit 6be1791c62
103 changed files with 12253 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from '../components/Toast';
import TaskModal from '../components/TaskModal';
import { apiFetch, FILE_BASE } from '../utils/api';
export default function TasksPage() {
const { user } = useAuth();
const toast = useToast();
const [tasks, setTasks] = useState([]);
const [totalTasks, setTotalTasks] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [selectedTask, setSelectedTask] = useState(null);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [confirmState, setConfirmState] = useState({ open: false, id: null });
const fetchTasks = useCallback(async (page = currentPage) => {
try {
const statusParam = filter !== 'all' ? `&status=${filter}` : '';
const res = await apiFetch(`/tasks?page=${page}&limit=20${statusParam}`);
const data = await res.json();
setTasks(data.tasks || []);
setTotalTasks(data.total || 0);
setTotalPages(data.totalPages || 1);
} catch (e) {
console.error('Fehler beim Laden der Aufgaben:', e);
} finally {
setLoading(false);
}
}, [currentPage, filter]);
useEffect(() => {
fetchTasks();
const interval = setInterval(() => fetchTasks(), 30000);
return () => clearInterval(interval);
}, [fetchTasks]);
const updateStatus = async (taskId, status) => {
try {
const res = await apiFetch(`/tasks/${taskId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
if (res.ok) {
await fetchTasks();
}
} catch (e) {
console.error('Fehler beim Aktualisieren:', e);
}
};
const deleteTask = async (id) => {
setConfirmState({ open: true, id });
};
const confirmDeleteTask = async () => {
const id = confirmState.id;
setConfirmState({ open: false, id: null });
try {
const res = await apiFetch(`/tasks/${id}`, { method: 'DELETE' });
if (!res.ok) {
toast.error('Fehler beim Löschen der Aufgabe.');
return;
}
toast.success('Aufgabe gelöscht.');
await fetchTasks();
} catch (e) {
toast.error('Netzwerkfehler beim Löschen.');
}
};
const filtered = searchQuery.trim()
? tasks.filter(t =>
t.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.user_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.template_name?.toLowerCase().includes(searchQuery.toLowerCase())
)
: tasks;
// Reset page when filter changes
useEffect(() => {
setCurrentPage(1);
setLoading(true);
fetchTasks(1);
}, [filter]);
const goToPage = (page) => {
setCurrentPage(page);
setLoading(true);
fetchTasks(page);
};
if (loading) {
return (
<div className="p-4 md:p-6 space-y-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div className="static-skeleton h-8 w-32"></div>
<div className="static-skeleton h-10 w-48 rounded-btn"></div>
</div>
<div className="static-skeleton h-40 w-full"></div>
<div className="static-skeleton h-40 w-full"></div>
</div>
);
}
return (
<div className="p-4 md:p-6">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h2 className="text-2xl font-bold">Aufgaben</h2>
<div className="flex flex-wrap items-center gap-2">
{/* Punkt 7: Suchleiste */}
<div className="form-control">
<input
type="text"
placeholder="Suche..."
className="input input-bordered input-sm w-44"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="join">
<button className={`btn join-item btn-sm ${filter === 'all' ? 'btn-active' : ''}`} onClick={() => setFilter('all')}>
Alle ({totalTasks})
</button>
<button className={`btn join-item btn-sm ${filter === 'offen' ? 'btn-active' : ''}`} onClick={() => setFilter('offen')}>
Offen
</button>
<button className={`btn join-item btn-sm ${filter === 'erledigt' ? 'btn-active' : ''}`} onClick={() => setFilter('erledigt')}>
Erledigt
</button>
</div>
</div>
</div>
{filtered.length === 0 ? (
<div className="hero min-h-[40vh] bg-base-200 rounded-lg">
<div className="hero-content text-center">
<div>
<p className="text-lg opacity-70">
{filter === 'all' ? 'Noch keine Aufgaben vorhanden.' : `Keine ${filter === 'offen' ? 'offenen' : 'erledigten'} Aufgaben.`}
</p>
</div>
</div>
</div>
) : (
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
<div className="divide-y divide-base-300">
{filtered.map((task) => (
<div
key={task.id}
className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors cursor-pointer"
onClick={() => setSelectedTask(task)}
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h4 className="font-bold text-base truncate">{task.title}</h4>
<span className="text-sm opacity-50 whitespace-nowrap">
{new Date(task.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
{task.user_name && <span className="text-sm opacity-70">{task.user_name}</span>}
{task.template_name && task.title !== task.template_name && (
<span className="text-sm opacity-70">{task.template_name}</span>
)}
</div>
{/* File attachments in row */}
{task.file_path && (
<div className="mt-2">
<a
href={`${FILE_BASE}${task.file_path}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
Dateianhang
</a>
</div>
)}
{/* File attachments from task values */}
{task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{task.values.filter(v => v.step_type === 'file_upload' && v.value).map(v => (
<a
key={v.id}
href={`${FILE_BASE}${v.value}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<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="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
{v.step_label}
</a>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
className={`btn btn-sm ${task.status === 'offen' ? 'btn-outline' : 'btn-success'}`}
onClick={(e) => {
e.stopPropagation();
updateStatus(task.id, task.status === 'offen' ? 'erledigt' : 'offen');
}}
>
{task.status === 'offen' ? 'Offen' : 'Erledigt'}
</button>
<button
className="btn btn-ghost btn-sm text-error"
onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }}
title="Aufgabe löschen"
>
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{selectedTask && (
<TaskModal task={selectedTask} currentUserRole={user?.role} onClose={() => setSelectedTask(null)} onTaskUpdated={() => fetchTasks()} />
)}
{/* Confirm Delete Modal */}
<ConfirmModal
open={confirmState.open}
title="Aufgabe löschen"
message="Möchtest du diese Aufgabe wirklich löschen?"
confirmLabel="Löschen"
onConfirm={confirmDeleteTask}
onCancel={() => setConfirmState({ open: false, id: null })}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-6">
<button
className="btn btn-sm btn-outline"
disabled={currentPage <= 1}
onClick={() => goToPage(currentPage - 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={() => goToPage(currentPage + 1)}
>
Weiter »
</button>
</div>
)}
</div>
);
}