- 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
268 lines
11 KiB
JavaScript
268 lines
11 KiB
JavaScript
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>
|
|
);
|
|
} |