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:
@@ -42,7 +42,7 @@ function AppContent() {
|
||||
|
||||
// #13: Map tab IDs to display labels for mobile navbar indicator
|
||||
const tabLabels = {
|
||||
dashboard: 'Vorlagen',
|
||||
dashboard: 'Dashboard',
|
||||
templates: 'Vorlageneditor',
|
||||
tasks: 'Aufgaben',
|
||||
users: 'Nutzerverwaltung',
|
||||
@@ -80,21 +80,21 @@ function AppContent() {
|
||||
const renderPages = () => (
|
||||
<>
|
||||
{/* Dashboard is always rendered for all users */}
|
||||
<div className={activeTab === 'dashboard' ? 'block' : 'hidden'}>
|
||||
<div className={activeTab === 'dashboard' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
|
||||
<Dashboard onNavigate={handleTabChange} />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className={activeTab === 'templates' ? 'block' : 'hidden'}>
|
||||
<div className={activeTab === 'templates' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
|
||||
<TemplatesPage />
|
||||
</div>
|
||||
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
|
||||
<div className={activeTab === 'tasks' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
|
||||
<TasksPage />
|
||||
</div>
|
||||
<div className={activeTab === 'users' ? 'block' : 'hidden'}>
|
||||
<div className={activeTab === 'users' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
|
||||
<UsersPage />
|
||||
</div>
|
||||
<div className={activeTab === 'auditlog' ? 'block' : 'hidden'}>
|
||||
<div className={activeTab === 'auditlog' ? 'h-full flex flex-col min-h-0' : 'hidden'}>
|
||||
<AuditLogPage />
|
||||
</div>
|
||||
</>
|
||||
@@ -106,10 +106,10 @@ function AppContent() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<div className="drawer lg:drawer-open h-screen overflow-hidden">
|
||||
<input id="sidebar-drawer" type="checkbox" className="drawer-toggle" />
|
||||
|
||||
<div className="drawer-content flex flex-col">
|
||||
<div className="drawer-content flex flex-col h-screen">
|
||||
{/* Mobile navbar */}
|
||||
<div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30">
|
||||
<label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button">
|
||||
@@ -120,10 +120,10 @@ function AppContent() {
|
||||
<span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto bg-base-100">
|
||||
{/* Main content — h-full für flex-1 Listen in den Seiten */}
|
||||
<main className="flex-1 min-h-0 overflow-auto bg-base-100">
|
||||
<ErrorBoundary>
|
||||
<div>
|
||||
<div className="h-full flex flex-col">
|
||||
{renderPages()}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast, ConfirmModal } from './Toast';
|
||||
import { apiFetch, FILE_BASE } from '../utils/api';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
export default function FillModal({ template, onSubmit, onClose }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function Sidebar({ activeTab, onTabChange }) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Vorlagen' },
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
// Vorlageneditor nur für Admins sichtbar
|
||||
...(user?.role === 'admin' ? [{ id: 'templates', label: 'Vorlageneditor' }] : []),
|
||||
...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { apiFetch, FILE_BASE } from '../utils/api';
|
||||
import { apiFetch, safeFileUrl } from '../utils/api';
|
||||
import { useToast, ConfirmModal } from './Toast';
|
||||
|
||||
const CUSTOM_STEP_TYPES = [
|
||||
@@ -843,13 +843,13 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
|
||||
);
|
||||
})}
|
||||
|
||||
{/* File attachment */}
|
||||
{task.file_path && (
|
||||
{/* File attachment — H1: nur validierte Upload-Pfade als Link */}
|
||||
{safeFileUrl(task.file_path) && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-sm opacity-70 mb-2">Dateianhang</h4>
|
||||
<div className="bg-base-200 p-3 rounded-lg">
|
||||
<a
|
||||
href={`${FILE_BASE}${task.file_path}`}
|
||||
href={safeFileUrl(task.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-outline btn-sm"
|
||||
@@ -861,7 +861,7 @@ export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdate
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!task.values || task.values.length === 0) && !task.file_path && (
|
||||
{(!task.values || task.values.length === 0) && !safeFileUrl(task.file_path) && (
|
||||
<div className="text-center py-4 opacity-50">Keine Werte vorhanden.</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
|
||||
// action: optionales Action-Element (z.B. "Starten"-Button im Dashboard).
|
||||
// Wenn gesetzt, ersetzt es die Admin-Aktionen (Bearbeiten/Löschen).
|
||||
export default function TemplateCard({ template, onOpen, onEdit, onDelete, action }) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
@@ -22,7 +24,9 @@ export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isAdmin && (
|
||||
{action ? (
|
||||
action
|
||||
) : isAdmin && (
|
||||
<>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
|
||||
Bearbeiten
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { apiFetch, safeFileUrl } from '../utils/api';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
@@ -107,7 +107,7 @@ export default function TasksPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="p-4 md:p-6 h-full flex flex-col min-h-0">
|
||||
<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">
|
||||
@@ -146,9 +146,11 @@ export default function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{filtered.map((task) => (
|
||||
<>
|
||||
{/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
|
||||
{filtered.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors cursor-pointer"
|
||||
@@ -168,11 +170,11 @@ export default function TasksPage() {
|
||||
<span className="text-sm opacity-70">{task.template_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* File attachments in row */}
|
||||
{task.file_path && (
|
||||
{/* File attachments in row — H1: nur validierte Upload-Pfade als Link */}
|
||||
{safeFileUrl(task.file_path) && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={`${FILE_BASE}${task.file_path}`}
|
||||
href={safeFileUrl(task.file_path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
@@ -183,13 +185,13 @@ export default function TasksPage() {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* File attachments from task values */}
|
||||
{task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && (
|
||||
{/* File attachments from task values — H1: nur validierte Upload-Pfade als Link */}
|
||||
{task.values?.filter(v => v.step_type === 'file_upload' && safeFileUrl(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 => (
|
||||
{task.values.filter(v => v.step_type === 'file_upload' && safeFileUrl(v.value)).map(v => (
|
||||
<a
|
||||
key={v.id}
|
||||
href={`${FILE_BASE}${v.value}`}
|
||||
href={safeFileUrl(v.value)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
@@ -223,8 +225,9 @@ export default function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedTask && (
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function TemplatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-6 h-full flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">Vorlageneditor</h2>
|
||||
{isAdmin && (
|
||||
@@ -135,8 +135,9 @@ export default function TemplatesPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{/* Scrollbare Table-Liste — füllt die restliche Seitenhöhe */}
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
<div className="divide-y divide-base-300 flex-1 min-h-0 overflow-y-auto">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
|
||||
@@ -5,6 +5,18 @@ export const FILE_BASE = import.meta.env.VITE_API_BASE
|
||||
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
|
||||
: '';
|
||||
|
||||
// H1: Sichere Download-URLs — nur Pfade, die exakt vom Upload-Endpoint stammen,
|
||||
// werden zu einer URL aufgelöst. Alles andere (javascript:, data:, absolute
|
||||
// URLs, Pfad-Traversal, ausführbare Endungen) gibt null zurück und wird im UI
|
||||
// nicht als Link gerendert. Spiegelt die serverseitige Whitelist in
|
||||
// backend/middleware/validation.js (inkl. Endungs-Beschränkung).
|
||||
const UPLOAD_PATH_PATTERN = /^\/api\/upload\/uploads\/[0-9]+-[0-9]+-[a-zA-Z0-9._-]*\.(pdf|png|jpg|jpeg|gif|txt|doc|docx|bin)$/;
|
||||
|
||||
export function safeFileUrl(path) {
|
||||
if (typeof path !== 'string' || !UPLOAD_PATH_PATTERN.test(path)) return null;
|
||||
return `${FILE_BASE}${path}`;
|
||||
}
|
||||
|
||||
// P5: CSRF token — the server derives it deterministically from the session and
|
||||
// self-heals the cookie on every GET. The frontend simply reads the cookie fresh
|
||||
// on every request, so memory/cookie can never drift apart (no more 403s).
|
||||
|
||||
Reference in New Issue
Block a user