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

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

3
frontend/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Frontend Environment Variables
# API base URL for backend (default: http://localhost:5000/api)
VITE_API_BASE=http://localhost:5000/api

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# VULN-17: Multi-stage build for production (no dev server in prod)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
RUN npm install -g serve@14
COPY --from=build /app/dist ./dist
EXPOSE 5173
CMD ["serve", "-s", "dist", "-l", "5173"]

19
frontend/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Portal</title>
<style>
/* FOUC prevention: hide root until CSS is loaded */
#root:not(:has(*)) { display: none; }
/* Font-display swap to prevent font layout shift */
@font-face { font-family: system-ui; src: local('system-ui'); font-display: swap; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2176
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "workflow-frontend",
"version": "1.0.0",
"description": "Workflow Portal Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.4",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.4.16",
"daisyui": "^4.4.19",
"postcss": "^8.5.26",
"tailwindcss": "^3.3.6",
"vite": "^8.0.16"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

143
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ToastProvider, useToast } from './components/Toast';
import { setGlobalErrorHandler } from './utils/api';
import AuthModal from './components/AuthModal';
import Sidebar from './components/Sidebar';
import Dashboard from './pages/Dashboard';
import TemplatesPage from './pages/TemplatesPage';
import TasksPage from './pages/TasksPage';
import UsersPage from './pages/UsersPage';
import AuditLogPage from './pages/AuditLogPage';
import ErrorBoundary from './components/ErrorBoundary';
import NotFound from './pages/NotFound';
// Punkt 12: React Query Client mit zentralem Error-Handling
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30s stale time (replaces manual 30s polling)
retry: 1,
refetchOnWindowFocus: false,
},
},
});
function AppContent() {
const { user, loading } = useAuth();
const toast = useToast();
const [activeTab, setActiveTab] = useState(user ? (user.role === 'admin' ? 'dashboard' : 'dashboard') : 'dashboard');
// Punkt 11: Global error handler for apiFetch
useEffect(() => {
setGlobalErrorHandler((type, message) => {
if (type === 'network' || type === 'server') {
toast.error(message);
} else if (type === 'rateLimit') {
toast.error(message);
}
});
}, [toast]);
// #13: Map tab IDs to display labels for mobile navbar indicator
const tabLabels = {
dashboard: 'Vorlagen',
templates: 'Vorlageneditor',
tasks: 'Aufgaben',
users: 'Nutzerverwaltung',
auditlog: 'Audit-Log',
};
// Punkt 1: Immediate tab switch - no skeleton/transition delay to avoid bounce
const handleTabChange = (newTab) => {
if (newTab === activeTab) return;
setActiveTab(newTab);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-base-100">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
);
}
if (!user) {
return <AuthModal />;
}
// Weg A: Render all pages once and keep them in DOM, only toggle visibility.
// This prevents re-mounting and re-loading data on every tab switch.
// Bug 7: Non-admins only get one Dashboard instance, not multiple.
const isAdmin = user.role === 'admin';
const renderPages = () => (
<>
{/* Dashboard is always rendered for all users */}
<div className={activeTab === 'dashboard' ? 'block' : 'hidden'}>
<Dashboard onNavigate={handleTabChange} />
</div>
{isAdmin && (
<>
<div className={activeTab === 'templates' ? 'block' : 'hidden'}>
<TemplatesPage />
</div>
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
<TasksPage />
</div>
<div className={activeTab === 'users' ? 'block' : 'hidden'}>
<UsersPage />
</div>
<div className={activeTab === 'auditlog' ? 'block' : 'hidden'}>
<AuditLogPage />
</div>
</>
)}
<div className={activeTab === 'dashboard' || (isAdmin && ['templates', 'tasks', 'users', 'auditlog'].includes(activeTab)) ? 'hidden' : 'block'}>
<NotFound onGoHome={() => handleTabChange('dashboard')} />
</div>
</>
);
return (
<div className="drawer lg:drawer-open">
<input id="sidebar-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col">
{/* 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">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
<span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span>
</div>
{/* Main content */}
<main className="flex-1 overflow-auto bg-base-100">
<ErrorBoundary>
<div>
{renderPages()}
</div>
</ErrorBoundary>
</main>
</div>
{/* Sidebar */}
<Sidebar activeTab={activeTab} onTabChange={handleTabChange} />
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ToastProvider>
<AppContent />
</ToastProvider>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
export default function AuthModal() {
const { login, register } = useAuth();
const [mode, setMode] = useState('login');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
try {
if (mode === 'login') {
await login(email, password);
} else {
await register(email, password, name);
// Punkt 2: Show success message (not as error alert)
setSuccess('Account erstellt! Ein Administrator muss dich freischalten, bevor du dich anmelden kannst.');
setMode('login');
setPassword('');
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const toggleMode = () => {
setMode(mode === 'login' ? 'register' : 'login');
setError('');
setSuccess('');
};
return (
<div className="min-h-screen flex items-center justify-center bg-base-200">
<div className="card bg-base-100 shadow-md w-full max-w-md">
<div className="card-body">
<h2 className="card-title text-2xl justify-center mb-4">
{mode === 'login' ? 'Anmelden' : 'Registrieren'}
</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mode === 'register' && (
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Name</span>
</label>
<input
type="text"
placeholder="Vollständiger Name"
className="input input-bordered w-full"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
)}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">E-Mail oder Anmeldename</span>
</label>
<input
type="text"
inputMode="email"
placeholder="E-Mail oder AD-Anmeldename"
className="input input-bordered w-full"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Passwort</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
placeholder="Passwort eingeben"
className="input input-bordered w-full pr-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
)}
</button>
</div>
</div>
{error && (
<div className="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span className="text-sm">{error}</span>
</div>
)}
{success && (
<div className="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span className="text-sm">{success}</span>
</div>
)}
<button
type="submit"
className={`btn btn-primary w-full ${loading ? 'loading' : ''}`}
disabled={loading}
>
{loading ? (
<span className="loading loading-spinner loading-sm"></span>
) : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
</form>
<div className="divider text-sm">ODER</div>
<button className="btn btn-outline btn-sm w-full" onClick={toggleMode}>
{mode === 'login' ? 'Neuen Account erstellen' : 'Zurück zur Anmeldung'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,889 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useToast, ConfirmModal } from './Toast';
const MAX_STEPS_PER_PAGE = 15;
// Punkt 8: Vorschau-Komponente (extrahiert wegen JSX-Parsing-Komplexität)
function EditorPreview({ name, pages, onClose }) {
const pageEntries = Object.entries(pages).sort(([a], [b]) => a - b);
return (
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal-box max-w-2xl w-11/12 max-h-[85vh]">
<h3 className="font-bold text-lg mb-1">Vorschau: {name}</h3>
<div className="overflow-y-auto max-h-[60vh] flex flex-col gap-3">
{pageEntries.map(([pageNum, steps]) => {
if (steps.length === 0) return null;
return (
<div key={pageNum}>
{pageEntries.length > 1 && (
<h4 className="font-semibold text-sm opacity-70 mb-2">Seite {pageNum}</h4>
)}
{steps.map((step, idx) => (
<div key={idx} className="bg-base-200 p-3 rounded-lg mb-2">
<label className="font-medium text-sm block mb-1">
{step.label || '(unbenannt)'}
{step.hidden && <span className="badge badge-xs badge-ghost ml-2">Versteckt</span>}
{step.ad_field && <span className="badge badge-xs badge-secondary ml-2">AD: {step.ad_field}</span>}
</label>
{step.type === 'text_input' && (
<input type="text" className="input input-bordered input-sm w-full" placeholder={step.label || 'Text eingeben...'} disabled />
)}
{step.type === 'checkbox' && (
<label className="label cursor-pointer justify-start gap-2">
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" disabled />
<span className="text-sm">Ja</span>
</label>
)}
{step.type === 'email' && (
<input type="text" className="input input-bordered input-sm w-full bg-base-300" disabled value={'auto@' + (step.email_domain || '...')} />
)}
{step.type === 'dropdown' && (
<select className="select select-bordered select-sm w-full" disabled>
<option>-- Bitte wählen --</option>
{(step.dropdown_options || '').split('|').map((o) => o.trim()).filter((o) => !!o).map((opt, i) => (
<option key={i}>{opt}</option>
))}
</select>
)}
{step.type === 'ad_password' && (
<input type="text" className="input input-bordered input-sm w-full bg-base-300" disabled value="********" />
)}
</div>
))}
</div>
);
})}
</div>
<div className="modal-action">
<button className="btn btn-sm" onClick={onClose}>Schließen</button>
</div>
</div>
</div>
);
}
const STEP_TYPES = [
{ value: 'text_input', label: 'Text-Feld' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'dropdown', label: 'Dropdown' },
{ value: 'email', label: 'E-Mail (auto)' },
{ value: 'ad_password', label: 'AD-Standardpasswort' },
];
const AD_FIELD_OPTIONS = [
{ value: '', label: '— Kein AD-Feld —' },
{ value: 'givenName', label: 'Vorname (givenName)' },
{ value: 'sn', label: 'Nachname (sn)' },
{ value: 'mail', label: 'E-Mail (mail)' },
{ value: 'sAMAccountName', label: 'Anmeldename (sAMAccountName)' },
{ value: 'displayName', label: 'Anzeigename (displayName)' },
{ value: 'department', label: 'Abteilung (department)' },
{ value: 'telephoneNumber', label: 'Telefon (telephoneNumber)' },
{ value: 'title', label: 'Position (title)' },
{ value: 'physicalDeliveryOfficeName', label: 'Büro (physicalDeliveryOfficeName)' },
{ value: 'company', label: 'Firma (company)' },
{ value: 'description', label: 'Beschreibung (description)' },
{ value: 'wWWHomePage', label: 'Webseite (wWWHomePage)' },
{ value: 'streetAddress', label: 'Straße (streetAddress)' },
{ value: 'postOfficeBox', label: 'Postfach (postOfficeBox)' },
{ value: 'l', label: 'Ort (l)' },
{ value: 'st', label: 'Bundesland/Kanton (st)' },
{ value: 'postalCode', label: 'PLZ (postalCode)' },
{ value: 'c', label: 'Land/Region (c)' },
];
export default function EditorModal({ template, onSave, onClose }) {
const isEditing = !!template;
const toast = useToast();
const [name, setName] = useState('');
const [isAssignable, setIsAssignable] = useState(false);
const [allowsFileUpload, setAllowsFileUpload] = useState(false);
const [adCreate, setAdCreate] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pages, setPages] = useState({ 1: [] });
const [saving, setSaving] = useState(false);
const [dragIdx, setDragIdx] = useState(null);
const [dragOverIdx, setDragOverIdx] = useState(null);
const dragItemRef = useRef(null);
// Punkt 8: Vorschau-Modus
const [showPreview, setShowPreview] = useState(false);
// Punkt 16: Dirty-Check
const [dirtyConfirm, setDirtyConfirm] = useState(false);
const isDirty = () => {
if (!template) return name.trim() !== '' || Object.values(pages).some(p => p.length > 0);
return name !== (template.name || '') ||
isAssignable !== !!template.is_assignable || allowsFileUpload !== !!template.allows_file_upload ||
adCreate !== !!template.ad_create;
};
const handleClose = () => {
if (isDirty()) {
setDirtyConfirm(true);
} else {
onClose();
}
};
// Esc to close (with dirty check)
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') handleClose(); };
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
useEffect(() => {
if (template) {
setName(template.name || '');
setIsAssignable(!!template.is_assignable);
setAllowsFileUpload(!!template.allows_file_upload);
setAdCreate(!!template.ad_create);
const parsed = {};
(template.steps || []).forEach((s) => {
const page = s.page_num || 1;
if (!parsed[page]) parsed[page] = [];
parsed[page].push({
label: s.label,
type: s.type,
email_domain: s.email_domain || '',
email_source_fields: s.email_source_fields || '',
dropdown_options: s.dropdown_options || '',
ad_field: s.ad_field || '',
ad_prefix: s.ad_prefix || '',
hidden: !!s.hidden,
});
});
if (!parsed[1]) parsed[1] = [];
setPages(parsed);
setCurrentPage(Math.min(...Object.keys(parsed).map(Number)));
}
}, [template]);
const addPage = () => {
const maxPage = Math.max(...Object.keys(pages).map(Number));
const newPage = maxPage + 1;
setPages((prev) => ({ ...prev, [newPage]: [] }));
setCurrentPage(newPage);
};
const removePage = (pageNum) => {
if (Object.keys(pages).length <= 1) return; // Can't remove last page
setPages((prev) => {
const updated = { ...prev };
delete updated[pageNum];
return updated;
});
if (currentPage === pageNum) {
const remaining = Object.keys(pages).map(Number).filter((p) => p !== pageNum);
setCurrentPage(Math.min(...remaining));
}
};
const addStep = useCallback(() => {
if ((pages[currentPage] || []).length >= MAX_STEPS_PER_PAGE) {
toast.warning(`Maximal ${MAX_STEPS_PER_PAGE} Aufgaben pro Seite erlaubt!`);
return;
}
setPages((prev) => ({
...prev,
[currentPage]: [...(prev[currentPage] || []), { label: '', type: 'text_input', email_domain: '', email_source_fields: '', dropdown_options: '', ad_field: '', ad_prefix: '', hidden: false }],
}));
}, [currentPage, pages]);
const updateStep = (idx, field, value) => {
setPages((prev) => {
const updated = [...(prev[currentPage] || [])];
updated[idx] = { ...updated[idx], [field]: value };
if (field === 'type' && value !== 'email') {
updated[idx].email_domain = '';
if (updated[idx].ad_field !== 'displayName') {
updated[idx].email_source_fields = '';
}
}
if (field === 'type' && value !== 'dropdown' && updated[idx].ad_field !== 'displayName') {
updated[idx].dropdown_options = '';
}
if (field === 'type' && value === 'ad_password') {
updated[idx].ad_field = 'password';
}
if (field === 'type' && value !== 'ad_password' && updated[idx].ad_field === 'password') {
updated[idx].ad_field = '';
}
// When changing ad_field, preserve relevant fields
if (field === 'ad_field') {
if (value !== 'displayName' && value !== 'email' && value !== 'sAMAccountName') {
updated[idx].email_source_fields = '';
}
if (value !== 'displayName' && value !== 'dropdown' && value !== 'password') {
updated[idx].dropdown_options = '';
}
}
return { ...prev, [currentPage]: updated };
});
};
const removeStep = (idx) => {
setPages((prev) => ({
...prev,
[currentPage]: (prev[currentPage] || []).filter((_, i) => i !== idx),
}));
};
// Drag & Drop handlers
const [isOverInput, setIsOverInput] = useState(false);
const handleDragStart = (idx) => {
if (isOverInput) return;
setDragIdx(idx);
dragItemRef.current = idx;
};
const handleDragOver = (e, idx) => {
e.preventDefault();
setDragOverIdx(idx);
};
const handleDragEnd = () => {
if (dragIdx !== null && dragOverIdx !== null && dragIdx !== dragOverIdx) {
setPages((prev) => {
const steps = [...(prev[currentPage] || [])];
const [moved] = steps.splice(dragIdx, 1);
steps.splice(dragOverIdx, 0, moved);
return { ...prev, [currentPage]: steps };
});
}
setDragIdx(null);
setDragOverIdx(null);
dragItemRef.current = null;
};
const handleInputHover = (hovering) => {
setIsOverInput(hovering);
};
const getAllTextSteps = () => {
const steps = [];
Object.entries(pages).forEach(([pageNum, pageSteps]) => {
pageSteps.forEach((step, idx) => {
if (step.type === 'text_input' && step.label.trim()) {
steps.push({ pageNum: parseInt(pageNum), idx, label: step.label.trim() });
}
});
});
return steps;
};
const handleSave = async () => {
if (!name.trim()) {
toast.warning('Bitte einen Vorlagen-Namen eingeben!');
return;
}
for (const [pageNum, steps] of Object.entries(pages)) {
for (let i = 0; i < steps.length; i++) {
if (!steps[i].label.trim()) {
toast.warning(`Bitte Label für Aufgabe ${i + 1} auf Seite ${pageNum} eingeben!`);
return;
}
if (steps[i].type === 'email' && !steps[i].email_domain.trim()) {
toast.warning(`Bitte E-Mail-Domain für "${steps[i].label}" auf Seite ${pageNum} eingeben!`);
return;
}
}
}
const flatSteps = [];
Object.entries(pages).forEach(([pageNum, steps]) => {
steps.forEach((step) => {
const stepData = {
page_num: parseInt(pageNum),
label: step.label,
type: step.type,
step_order: flatSteps.length + 1,
};
if (step.type === 'email') {
stepData.email_domain = step.email_domain;
stepData.email_source_fields = step.email_source_fields;
}
if (step.type === 'dropdown') {
stepData.dropdown_options = step.dropdown_options;
}
if (step.ad_field === 'displayName') {
stepData.email_source_fields = step.email_source_fields;
stepData.dropdown_options = step.dropdown_options;
}
if (step.ad_field === 'sAMAccountName') {
stepData.email_source_fields = step.email_source_fields;
}
if (step.ad_field === 'password') {
stepData.dropdown_options = step.dropdown_options;
}
if (step.ad_field) {
stepData.ad_field = step.ad_field;
if (step.ad_prefix) stepData.ad_prefix = step.ad_prefix;
}
if (step.hidden) {
stepData.hidden = true;
}
flatSteps.push(stepData);
});
});
setSaving(true);
try {
await onSave({ name: name.trim(), is_assignable: isAssignable, allows_file_upload: allowsFileUpload, ad_create: adCreate, steps: flatSteps });
} finally {
setSaving(false);
}
};
const currentSteps = pages[currentPage] || [];
const pageNumbers = Object.keys(pages).map(Number).sort((a, b) => a - b);
const allTextSteps = getAllTextSteps();
return (
<>
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && handleClose()}>
<div className="modal-box max-w-3xl w-11/12 max-h-[90vh] overflow-y-auto">
<h3 className="font-bold text-lg mb-4">
{isEditing ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
</h3>
{/* Template Name */}
<div className="form-control mb-3">
<label className="label">
<span className="label-text font-medium">Vorlagen-Name</span>
</label>
<input
type="text"
placeholder="z.B. IT-Onboarding-Checkliste"
className="input input-bordered w-full"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{/* Is Assignable Toggle */}
<div className="form-control mb-2">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={isAssignable}
onChange={(e) => setIsAssignable(e.target.checked)}
/>
<div>
<span className="label-text font-medium">Als Aufgabe zuweisbar</span>
<p className="text-xs opacity-60">User können diese Vorlage ausfüllen und als Aufgabe absenden</p>
</div>
</label>
</div>
{/* File Upload Toggle */}
<div className="form-control mb-4">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
className="checkbox checkbox-primary"
checked={allowsFileUpload}
onChange={(e) => setAllowsFileUpload(e.target.checked)}
/>
<div>
<span className="label-text font-medium">Datei-Anhang erlauben</span>
<p className="text-xs opacity-60">Mitarbeiter können Dateien zur Aufgabe hochladen</p>
</div>
</label>
</div>
{/* AD User Creation Toggle */}
<div className="form-control mb-4">
<label className="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
className="checkbox checkbox-secondary"
checked={adCreate}
onChange={(e) => setAdCreate(e.target.checked)}
/>
<div>
<span className="label-text font-medium">AD-Benutzer anlegen</span>
<p className="text-xs opacity-60">Ermöglicht das direkte Anlegen eines Benutzers im Active Directory aus dieser Vorlage</p>
</div>
</label>
</div>
{/* Page Tabs */}
<div className="flex flex-wrap gap-2 mb-4 items-center">
{pageNumbers.map((pageNum) => {
const isActive = currentPage === pageNum;
return (
<button
key={pageNum}
type="button"
className={`flex items-center rounded-full overflow-hidden border transition-colors ${
isActive ? 'bg-primary border-primary' : 'bg-base-100 border-base-300 hover:bg-base-200'
}`}
onClick={() => setCurrentPage(pageNum)}
>
<span className={`px-3 py-1.5 text-sm font-medium flex items-center gap-1.5 ${
isActive ? 'text-primary-content' : 'text-base-content'
}`}>
Seite {pageNum}
<span className={`badge badge-sm ${isActive ? 'badge-outline badge-primary-content' : 'badge-neutral'}`}>
{(pages[pageNum] || []).length}
</span>
</span>
{Object.keys(pages).length > 1 && (
<span
className={`px-2 py-1.5 h-full flex items-center justify-center transition-colors ${
isActive
? 'text-primary-content/70 hover:text-primary-content hover:bg-primary-focus'
: 'text-base-content/60 hover:text-error hover:bg-error/10'
}`}
onClick={(e) => {
e.stopPropagation();
removePage(pageNum);
}}
title="Seite entfernen"
role="button"
aria-label="Seite entfernen"
>
<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="M6 18L18 6M6 6l12 12" /></svg>
</span>
)}
</button>
);
})}
<button className="btn btn-outline btn-sm rounded-full" onClick={addPage}>
+ Seite
</button>
</div>
{/* Steps for current page */}
<div className="mb-4">
<div className="flex justify-between items-center mb-3">
<span className="font-semibold">Aufgaben (Seite {currentPage})</span>
<span className="text-sm opacity-70">
{currentSteps.length} / {MAX_STEPS_PER_PAGE}
</span>
</div>
<progress
className="progress progress-primary w-full mb-3"
value={currentSteps.length}
max={MAX_STEPS_PER_PAGE}
></progress>
{currentSteps.length === 0 && (
<div className="text-center py-6 opacity-50">
Noch keine Aufgaben auf Seite {currentPage}.<br />
Klicke auf "+ Aufgabe hinzufügen" oder drücke Enter.
</div>
)}
{currentSteps.map((step, idx) => (
<div
key={idx}
draggable={!isOverInput}
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDragLeave={() => setDragOverIdx(null)}
onDragEnd={handleDragEnd}
className={`bg-base-200 p-3 rounded-lg mb-3 ${!isOverInput ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'} transition-all ${
dragIdx === idx ? 'opacity-50' : ''
} ${
dragOverIdx === idx && dragIdx !== idx ? 'border-t-2 border-primary' : ''
}`}
>
<div className="flex gap-2 items-center">
<span className="font-mono text-sm opacity-50 w-6 text-center select-none">{idx + 1}</span>
{/* Drag handle */}
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 opacity-30 hover:opacity-70 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" /></svg>
<select
className="select select-bordered select-sm w-36"
value={step.type}
onChange={(e) => updateStep(idx, 'type', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
{STEP_TYPES.map((st) => (
<option key={st.value} value={st.value}>{st.label}</option>
))}
</select>
<input
type="text"
placeholder="Aufgaben-Label eingeben..."
className="input input-bordered input-sm flex-1"
value={step.label}
onChange={(e) => updateStep(idx, 'label', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addStep();
}
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
<button
className="btn btn-ghost btn-xs btn-circle"
onClick={() => updateStep(idx, 'hidden', !step.hidden)}
title={step.hidden ? 'Konfiguration anzeigen (für User sichtbar)' : 'Konfiguration verstecken (für User unsichtbar)'}
>
{step.hidden ? (
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
) : (
<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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
)}
</button>
<button
className="btn btn-ghost btn-xs btn-circle text-error"
onClick={() => removeStep(idx)}
title="Aufgabe entfernen"
>
<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>
{/* Email configuration */}
{step.type === 'email' && (
<div className="mt-3 ml-8 flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Domain:</label>
<input
type="text"
placeholder="z.B. @amt-leezen.de"
className="input input-bordered input-sm flex-1"
value={step.email_domain || ''}
onChange={(e) => updateStep(idx, 'email_domain', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
</div>
<div className="flex items-start gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0 pt-0.5">Quellfelder:</label>
<div className="flex-1 flex flex-col gap-1">
<select
className="select select-bordered select-sm w-full"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const second = fields[1] || '';
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">-- Feld 1 --</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`f1-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
<select
className="select select-bordered select-sm w-full"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const first = fields[0] || '';
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">-- Feld 2 (optional) --</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`f2-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
{allTextSteps.filter((s) => !(s.pageNum === currentPage && s.idx === idx)).length === 0 && (
<p className="text-xs opacity-50">Keine Text-Felder vorhanden. Füge zuerst Text-Felder hinzu.</p>
)}
</div>
</div>
{step.email_domain && step.email_source_fields && (
<p className="text-xs opacity-60 ml-8">
Vorschau: {(step.email_source_fields || '').split('|').map(f => f.trim().toLowerCase().replace(/\s+/g, '.')).join('.')}{step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain}
</p>
)}
</div>
)}
{/* Dropdown configuration */}
{step.type === 'dropdown' && (
<div className="mt-3 ml-8 flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Optionen:</label>
<input
type="text"
placeholder="Option 1 | Option 2 | Option 3 (max. 5)"
className="input input-bordered input-sm flex-1"
value={step.dropdown_options || ''}
onChange={(e) => {
const val = e.target.value;
const options = val.split('|').map(o => o.trim()).filter(Boolean);
if (options.length <= 5) {
updateStep(idx, 'dropdown_options', val);
}
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
</div>
{step.dropdown_options && (
<div className="ml-8 flex flex-wrap gap-1">
{step.dropdown_options.split('|').map((o, i) => o.trim()).filter(Boolean).map((opt, i) => (
<span key={i} className="badge badge-outline badge-sm">{opt}</span>
))}
</div>
)}
</div>
)}
{/* AD field mapping - shown when ad_create is enabled */}
{adCreate && (
<div className="mt-2 ml-8 flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">AD-Feld:</label>
<select
className="select select-bordered select-sm flex-1"
value={step.ad_field || ''}
onChange={(e) => updateStep(idx, 'ad_field', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
{AD_FIELD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)}
{/* AD prefix field - shown for address fields */}
{adCreate && ['streetAddress', 'postOfficeBox', 'l', 'st', 'postalCode', 'c'].includes(step.ad_field) && (
<div className="mt-2 ml-8 flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Präfix:</label>
<input
type="text"
placeholder="Fester Präfix (z.B. DE für Land)"
className="input input-bordered input-sm flex-1"
value={step.ad_prefix || ''}
onChange={(e) => updateStep(idx, 'ad_prefix', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
</div>
)}
{/* ad_password input field */}
{adCreate && step.ad_field === 'password' && (
<div className="mt-2 ml-8 flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Passwort:</label>
<input
type="text"
placeholder="Standardpasswort für neue AD-Benutzer"
className="input input-bordered input-sm flex-1"
value={step.dropdown_options || ''}
onChange={(e) => updateStep(idx, 'dropdown_options', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
</div>
)}
{/* ad_password info */}
{adCreate && step.ad_field === 'password' && (
<div className="mt-1 ml-8">
<p className="text-xs text-secondary">Wird automatisch als Initialpasswort für den neuen AD-Benutzer verwendet.{step.dropdown_options ? ` Vergebenes Passwort: ${step.dropdown_options}` : ' Standard: InitialPass1!'}</p>
</div>
)}
{/* displayName configuration */}
{adCreate && step.ad_field === 'displayName' && (
<div className="mt-3 ml-8 flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 1:</label>
<select
className="select select-bordered select-sm flex-1"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const second = fields[1] || '';
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">— Quellfeld wählen —</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`dn1-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 2:</label>
<select
className="select select-bordered select-sm flex-1"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const first = fields[0] || '';
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">— Quellfeld wählen (optional) —</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`dn2-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Suffix:</label>
<input
type="text"
placeholder="z.B. Amt Leezen"
className="input input-bordered input-sm flex-1"
value={step.dropdown_options || ''}
onChange={(e) => updateStep(idx, 'dropdown_options', e.target.value)}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
/>
</div>
{step.email_source_fields && (
<p className="text-xs opacity-60 ml-8">
Vorschau: {(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean).join(', ')}{step.dropdown_options ? (step.dropdown_options.trim().startsWith('(') ? ` ${step.dropdown_options.trim()}` : ` (${step.dropdown_options.trim()})`) : ''}
</p>
)}
<p className="text-xs text-secondary ml-8">Wird als Anzeigename im AD gesetzt (z.B. &quot;Mustermann, Thomas (Amt Leezen)&quot;). Für User nicht sichtbar.</p>
</div>
)}
{/* sAMAccountName configuration */}
{/* sAMAccountName configuration */}
{adCreate && step.ad_field === 'sAMAccountName' && (
<div className="mt-3 ml-8 flex flex-col gap-2">
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 1:</label>
<select
className="select select-bordered select-sm flex-1"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const second = fields[1] || '';
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">— Quellfeld wählen —</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`sam1-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 2:</label>
<select
className="select select-bordered select-sm flex-1"
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
onChange={(e) => {
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const first = fields[0] || '';
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
}}
onMouseEnter={() => handleInputHover(true)}
onMouseLeave={() => handleInputHover(false)}
>
<option value="">— Quellfeld wählen (optional) —</option>
{allTextSteps
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
.map((s, i) => (
<option key={`sam2-${s.pageNum}-${s.idx}`} value={s.label}>
{i + 1}: {s.label}
</option>
))
}
</select>
</div>
{step.email_source_fields && (
<p className="text-xs opacity-60 ml-8">
Vorschau UPN: {(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean).join('.').toLowerCase().replace(/\s+/g, '.')}
</p>
)}
<p className="text-xs text-secondary ml-8">UPN-Format: Vorname.Nachname (z.B. thomas.mustermann). Prä-Windows-2000-Name bleibt NachnameV (z.B. MustermannT).</p>
</div>
)}
</div>
))}
{currentSteps.length < MAX_STEPS_PER_PAGE && (
<button className="btn btn-outline btn-sm mt-2" onClick={addStep}>
+ Aufgabe hinzufügen
</button>
)}
</div>
{/* Actions */}
<div className="modal-action">
<button className="btn btn-ghost btn-sm" onClick={handleClose} disabled={saving}>
Abbrechen
</button>
{/* Punkt 8: Vorschau-Button */}
{name.trim() && Object.values(pages).some(p => p.length > 0) && (
<button
className="btn btn-outline btn-sm"
onClick={() => setShowPreview(true)}
disabled={saving}
>
<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 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
Vorschau
</button>
)}
<button
className={`btn btn-primary btn-sm ${saving ? 'loading' : ''}`}
onClick={handleSave}
disabled={saving}
>
{saving ? <span className="loading loading-spinner loading-sm"></span> : 'Speichern'}
</button>
</div>
</div>
</div>
{/* Punkt 8: Vorschau-Modal */}
{showPreview && <EditorPreview name={name} pages={pages} onClose={() => setShowPreview(false)} />}
{/* Punkt 16: Dirty-Check ConfirmModal */}
<ConfirmModal
open={dirtyConfirm}
title="Ungespeicherte Änderungen"
message="Du hast Änderungen vorgenommen, die noch nicht gespeichert wurden. Möchtest du wirklich abbrechen?"
confirmLabel="Abbrechen"
cancelLabel="Weiter bearbeiten"
onConfirm={() => { setDirtyConfirm(false); onClose(); }}
onCancel={() => setDirtyConfirm(false)}
/>
</>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
export default class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="card bg-base-200 shadow-lg max-w-md w-full">
<div className="card-body text-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-error mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="text-xl font-bold mb-2">Etwas ist schiefgelaufen</h2>
<p className="text-sm opacity-70 mb-4">Ein unerwarteter Fehler ist aufgetreten.</p>
<button className="btn btn-primary btn-sm" onClick={() => window.location.reload()}>
Seite neu laden
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,432 @@
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from './Toast';
import { apiFetch, FILE_BASE } from '../utils/api';
export default function FillModal({ template, onSubmit, onClose }) {
const { user: currentUser } = useAuth();
const isAdmin = currentUser?.role === 'admin';
const toast = useToast();
const pageNumbers = [...new Set(template.steps.map((s) => s.page_num))].sort((a, b) => a - b);
const totalPages = pageNumbers.length;
const allowsFileUpload = !!template.allows_file_upload;
const [currentPage, setCurrentPage] = useState(pageNumbers[0] || 1);
const [values, setValues] = useState(() => {
const initial = {};
template.steps.forEach((s) => {
if (s.type === 'checkbox') {
initial[s.id] = false;
} else {
initial[s.id] = '';
}
});
return initial;
});
const [uploadedFile, setUploadedFile] = useState(null);
const [uploadedFileInfo, setUploadedFileInfo] = useState(null);
const [submitting, setSubmitting] = useState(false);
// Punkt 16: Dirty-Check
const [dirtyConfirm, setDirtyConfirm] = useState(false);
const initialValuesRef = useRef(null);
useEffect(() => {
const initial = {};
template.steps.forEach((s) => {
initial[s.id] = s.type === 'checkbox' ? false : '';
});
initialValuesRef.current = initial;
}, [template]);
const isDirty = () => {
if (!initialValuesRef.current) return false;
return Object.keys(values).some(key => values[key] !== initialValuesRef.current[key]) || !!uploadedFile;
};
const handleClose = () => {
if (isDirty()) {
setDirtyConfirm(true);
} else {
onClose();
}
};
// Esc to close (with dirty check)
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') handleClose(); };
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, []);
const pageSteps = template.steps.filter((s) => s.page_num === currentPage);
const currentIdx = pageNumbers.indexOf(currentPage);
const isFirstPage = currentIdx === 0;
const isLastPage = pageNumbers.length - 1 === currentIdx;
const handleChange = (stepId, value) => {
setValues((prev) => ({ ...prev, [stepId]: value }));
};
const handleFileUpload = async (file) => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await apiFetch('/upload', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const data = await res.json();
toast.error(data.error || 'Fehler beim Hochladen der Datei.');
return;
}
const data = await res.json();
setUploadedFile(data.url);
setUploadedFileInfo(data);
} catch (e) {
toast.error('Netzwerkfehler beim Hochladen der Datei.');
}
};
const generateEmail = (step) => {
if (step.type !== 'email' || !step.email_domain) return '';
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
if (sourceFields.length === 0) return '';
const parts = sourceFields.map((fieldLabel) => {
const sourceStep = template.steps.find((s) => s.label === fieldLabel);
if (!sourceStep || !values[sourceStep.id]) return '';
return values[sourceStep.id].trim().toLowerCase().replace(/\s+/g, '.');
}).filter(Boolean);
if (parts.length === 0) return '';
const domain = step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain;
return parts.join('.') + domain;
};
const goNext = () => {
if (!isLastPage) setCurrentPage(pageNumbers[currentIdx + 1]);
};
const goPrev = () => {
if (!isFirstPage) setCurrentPage(pageNumbers[currentIdx - 1]);
};
const handleSubmit = async () => {
const taskValues = template.steps.map((s) => {
let value = '';
if (s.type === 'checkbox') {
// Checkbox: store boolean state as value string for display, plus is_checked flag
value = values[s.id] ? 'true' : 'false';
} else if (s.type === 'email') {
value = generateEmail(s);
} else if (s.ad_field === 'password') {
// password: use the password from the template, or fallback to InitialPass1!
value = s.dropdown_options || 'InitialPass1!';
} else if (s.ad_field === 'displayName') {
// displayName: auto-generate from source fields + suffix
// Format: "Quellfeld1, Quellfeld2 (Suffix)" e.g. "Mustermann, Thomas (Amt Leezen)"
const sourceFields = (s.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const sourceValues = sourceFields.map(fieldLabel => {
const sourceStep = template.steps.find(st => st.label === fieldLabel);
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean);
const suffix = (s.dropdown_options || '').trim();
if (sourceValues.length > 0) {
if (suffix) {
// If suffix already starts with '(', use it as-is; otherwise wrap in parentheses
value = sourceValues.join(', ') + (suffix.startsWith('(') ? ` ${suffix}` : ` (${suffix})`);
} else {
value = sourceValues.join(', ');
}
} else {
value = '';
}
} else if (s.ad_field === 'sAMAccountName') {
// Auto-generate username from source fields if configured, otherwise from givenName/sn
// UPN format: Vorname.Nachname (e.g. thomas.mustermann)
// Pre-Windows 2000 format: NachnameV (e.g. MustermannT) - always auto-generated
const sourceFields = (s.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
if (sourceFields.length > 0) {
const sourceValues = sourceFields.map(fieldLabel => {
const sourceStep = template.steps.find(st => st.label === fieldLabel);
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean);
if (sourceValues.length > 0) {
value = sourceValues.join('.')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '');
} else {
value = '';
}
} else {
// Fallback: Nachname + FirstInitial (e.g. MustermannT)
const vornameStep = template.steps.find(st => st.ad_field === 'givenName');
const nachnameStep = template.steps.find(st => st.ad_field === 'sn');
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
if (vorname && nachname) {
value = (nachname + vorname.charAt(0))
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^a-zA-Z0-9]/g, '');
} else {
value = values[s.id] || '';
}
}
} else {
value = values[s.id] || '';
}
return {
step_id: s.id,
value,
is_checked: s.type === 'checkbox' ? (values[s.id] ? 1 : 0) : 0,
file_path: null,
};
});
setSubmitting(true);
try {
await onSubmit({
template_id: template.id,
title: template.name,
values: taskValues,
file_path: (allowsFileUpload && uploadedFile) ? uploadedFile : null,
});
} finally {
setSubmitting(false);
}
};
return (
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && handleClose()}>
<div className="modal-box max-w-2xl w-11/12 max-h-[85vh]">
<h3 className="font-bold text-lg mb-1">{template.name}</h3>
{template.description && (
<p className="text-sm opacity-70 mb-4">{template.description}</p>
)}
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex flex-wrap gap-2 mb-4">
{pageNumbers.map((page) => {
const count = template.steps.filter((s) => s.page_num === page).length;
return (
<button
key={page}
className={`btn btn-sm ${currentPage === page ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setCurrentPage(page)}
>
Seite {page}
<span className="badge badge-sm ml-1">{count}</span>
</button>
);
})}
</div>
)}
{/* Steps - scrollable area */}
<div className="overflow-y-auto max-h-[50vh] flex flex-col gap-3 mb-4 pr-1">
{pageSteps.length === 0 && (
<div className="text-center py-4 opacity-50">
Keine Aufgaben auf Seite {currentPage}.
</div>
)}
{pageSteps.map((step) => {
// Hide hidden steps from non-admins
if (!!step.hidden && !isAdmin) return null;
return (
<div key={step.id} className="bg-base-200 p-3 rounded-lg">
<label className="font-medium text-sm mb-1 block">
{step.label}
</label>
{!step.hidden && step.type === 'text_input' && (
<input
type="text"
placeholder={step.label}
className="input input-bordered input-sm w-full"
value={values[step.id] || ''}
onChange={(e) => handleChange(step.id, e.target.value)}
/>
)}
{!step.hidden && step.type === 'checkbox' && (
<label className="label cursor-pointer justify-start gap-2">
<input
type="checkbox"
className="checkbox checkbox-primary checkbox-sm"
checked={!!values[step.id]}
onChange={(e) => handleChange(step.id, e.target.checked)}
/>
<span className="label-text text-sm">Ja</span>
</label>
)}
{!step.hidden && step.type === 'email' && (
<div>
<input
type="text"
className="input input-bordered input-sm w-full bg-base-300"
value={generateEmail(step)}
readOnly
placeholder={step.email_source_fields ? 'Wird automatisch generiert...' : 'Quellfeld wählen'}
/>
<p className="text-xs opacity-60 mt-1">
Wird automatisch aus &quot;{step.email_source_fields}&quot; und &quot;{step.email_domain}&quot; generiert
</p>
</div>
)}
{!step.hidden && step.type === 'dropdown' && (
<select
className="select select-bordered select-sm w-full"
value={values[step.id] || ''}
onChange={(e) => handleChange(step.id, e.target.value)}
>
<option value="">-- Bitte wählen --</option>
{(step.dropdown_options || '').split('|').map(o => o.trim()).filter(Boolean).map((opt, i) => (
<option key={i} value={opt}>{opt}</option>
))}
</select>
)}
{/* Admin-only: show auto-generated AD fields as read-only */}
{!!step.hidden && step.ad_field === 'password' && isAdmin && (
<div>
<input
type="text"
className="input input-bordered input-sm w-full bg-base-300"
value={step.dropdown_options || 'InitialPass1!'}
readOnly
/>
<p className="text-xs text-secondary mt-1">AD-Standardpasswort (automatisch)</p>
</div>
)}
{!!step.hidden && step.ad_field === 'displayName' && isAdmin && (
<div>
<input
type="text"
className="input input-bordered input-sm w-full bg-base-300"
value={(() => {
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
const sourceValues = sourceFields.map(fieldLabel => {
const sourceStep = template.steps.find(st => st.label === fieldLabel);
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean);
const suffix = (step.dropdown_options || '').trim();
if (sourceValues.length === 0) return '';
if (suffix) {
return sourceValues.join(', ') + (suffix.startsWith('(') ? ` ${suffix}` : ` (${suffix})`);
}
return sourceValues.join(', ');
})()}
readOnly
/>
<p className="text-xs text-secondary mt-1">AD-Anzeigename (automatisch{step.dropdown_options ? `, Suffix: ${step.dropdown_options}` : ''})</p>
</div>
)}
{!!step.hidden && step.ad_field === 'sAMAccountName' && isAdmin && (
<div>
<input
type="text"
className="input input-bordered input-sm w-full bg-base-300"
value={(() => {
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
if (sourceFields.length > 0) {
const sourceValues = sourceFields.map(fieldLabel => {
const sourceStep = template.steps.find(st => st.label === fieldLabel);
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
}).filter(Boolean);
if (sourceValues.length > 0) {
return sourceValues.join('.')
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/\s+/g, '.')
.replace(/[^a-zA-Z0-9.]/g, '');
}
return '';
}
// Fallback: Nachname + FirstInitial
const vornameStep = template.steps.find(st => st.ad_field === 'givenName');
const nachnameStep = template.steps.find(st => st.ad_field === 'sn');
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
if (vorname && nachname) {
return (nachname + vorname.charAt(0))
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/[^a-zA-Z0-9]/g, '');
}
return '';
})()}
readOnly
/>
<p className="text-xs text-secondary mt-1">AD-Anmeldename (automatisch generiert)</p>
</div>
)}
</div>
);
})}
{/* File Upload Section - shown on last page if enabled */}
{allowsFileUpload && isLastPage && (
<div className="bg-base-200 p-3 rounded-lg border-2 border-dashed border-base-300">
<label className="font-medium text-sm mb-1 block">
Datei anhängen
</label>
<input
type="file"
className="file-input file-input-bordered file-input-sm w-full"
onChange={(e) => handleFileUpload(e.target.files[0])}
/>
{uploadedFileInfo && (
<p className="text-xs text-success mt-1">
{uploadedFileInfo.originalname} hochgeladen
</p>
)}
</div>
)}
</div>
{/* Actions */}
<div className="modal-action">
<button className="btn btn-ghost btn-sm" onClick={handleClose} disabled={submitting}>
Abbrechen
</button>
{!isFirstPage && (
<button className="btn btn-outline btn-sm" onClick={goPrev}>
Zurück
</button>
)}
{!isLastPage && (
<button className="btn btn-secondary btn-sm" onClick={goNext}>
Weiter
</button>
)}
{isLastPage && (
<button
className={`btn btn-primary btn-sm ${submitting ? 'loading' : ''}`}
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? <span className="loading loading-spinner loading-sm"></span> : (template.is_assignable ? 'Abschicken' : 'Fertig')}
</button>
)}
</div>
</div>
{/* Punkt 16: Dirty-Check ConfirmModal */}
<ConfirmModal
open={dirtyConfirm}
title="Ungespeicherte Änderungen"
message="Du hast Änderungen vorgenommen, die noch nicht gespeichert wurden. Möchtest du wirklich abbrechen?"
confirmLabel="Abbrechen"
cancelLabel="Weiter bearbeiten"
onConfirm={() => { setDirtyConfirm(false); onClose(); }}
onCancel={() => setDirtyConfirm(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
// Punkt 5: SVG Icons für jeden Tab
const tabIcons = {
dashboard: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
),
templates: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
),
tasks: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /></svg>
),
users: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
),
auditlog: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
),
};
export default function Sidebar({ activeTab, onTabChange }) {
const { user, logout } = useAuth();
// Punkt 19: Collapsible Sidebar State
const [collapsed, setCollapsed] = useState(false);
const tabs = [
{ id: 'dashboard', label: 'Vorlagen' },
{ id: 'templates', label: 'Vorlageneditor' },
...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []),
...(user?.role === 'admin' ? [{ id: 'users', label: 'Nutzerverwaltung' }] : []),
...(user?.role === 'admin' ? [{ id: 'auditlog', label: 'Audit-Log' }] : []),
];
return (
<div className="drawer-side z-40">
<label htmlFor="sidebar-drawer" className="drawer-overlay"></label>
<aside className={`${collapsed ? 'w-16' : 'w-64'} min-h-full bg-base-200 flex flex-col transition-all duration-300`}>
{/* Header */}
<div className="p-4 border-b border-base-300 flex items-center justify-between">
{!collapsed && (
<h1 className="text-xl font-bold truncate">Workflow Portal</h1>
)}
{/* Punkt 19: Collapse Toggle - hidden on mobile (drawer handles it) */}
<button
className="btn btn-ghost btn-xs btn-square hidden lg:flex"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Sidebar aufklappen' : 'Sidebar einklappen'}
>
<svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transition-transform ${collapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Navigation */}
<ul className="menu p-2 gap-1 flex-1">
{tabs.map((tab) => (
<li key={tab.id}>
<button
className={`flex items-center gap-3 ${activeTab === tab.id ? 'active' : ''} ${collapsed ? 'justify-center px-0' : ''}`}
onClick={() => {
onTabChange(tab.id);
// Close drawer on mobile after selection
const drawer = document.getElementById('sidebar-drawer');
if (drawer) drawer.checked = false;
}}
title={collapsed ? tab.label : undefined}
>
{tabIcons[tab.id]}
{!collapsed && <span>{tab.label}</span>}
</button>
</li>
))}
</ul>
{/* User Info & Logout */}
<div className={`p-4 border-t border-base-300 ${collapsed ? 'flex flex-col items-center gap-2' : ''}`}>
<div className={`flex items-center gap-3 ${collapsed ? '' : 'mb-3'}`}>
<div className="avatar placeholder">
<div className="bg-primary text-primary-content rounded-full w-10">
<span className="text-sm">{user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || '?'}</span>
</div>
</div>
{!collapsed && (
<div>
<div className="font-semibold text-sm">{user?.name || user?.email}</div>
<div className="badge badge-sm badge-primary">{user?.role}</div>
</div>
)}
</div>
{!collapsed && (
<button className="btn btn-outline btn-sm w-full" onClick={logout}>
Abmelden
</button>
)}
{collapsed && (
<button className="btn btn-ghost btn-xs btn-square" onClick={logout} title="Abmelden">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
</button>
)}
</div>
</aside>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useAuth } from '../context/AuthContext';
export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const pageCount = [...new Set(template.steps?.map((s) => s.page_num) || [])].length;
return (
<div className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors">
<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">
<h3 className="font-bold text-base truncate">{template.name}</h3>
{pageCount > 1 && (
<span className="badge badge-ghost badge-sm">{pageCount} Seiten</span>
)}
</div>
<p className="text-xs opacity-70 line-clamp-1 mt-1">
{template.description || 'Keine Beschreibung'}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && (
<>
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
Bearbeiten
</button>
<button className="btn btn-ghost btn-sm text-error" onClick={() => onDelete(template.id)} title="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>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
const ToastContext = createContext(null);
export function useToast() {
return useContext(ToastContext);
}
function ToastItem({ toast, onRemove }) {
const [exiting, setExiting] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setExiting(true);
setTimeout(() => onRemove(toast.id), 300);
}, toast.duration || 3000);
return () => clearTimeout(timer);
}, [toast.id, toast.duration, onRemove]);
const typeClass = {
success: 'alert-success',
error: 'alert-error',
warning: 'alert-warning',
info: 'alert-info',
}[toast.type] || 'alert-info';
return (
<div
className={`alert ${typeClass} shadow-lg transition-all duration-300 ${
exiting ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'
}`}
>
<div className="flex-1 flex items-center gap-2">
{toast.type === 'success' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
)}
{toast.type === 'error' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
)}
{toast.type === 'warning' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
)}
{toast.type === 'info' && (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
)}
<span className="text-sm">{toast.message}</span>
</div>
<button className="btn btn-ghost btn-xs" onClick={() => { setExiting(true); setTimeout(() => onRemove(toast.id), 300); }}>
<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="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
);
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = Date.now() + Math.random();
setToasts((prev) => [...prev, { id, message, type, duration }]);
}, []);
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const success = useCallback((msg, dur) => addToast(msg, 'success', dur), [addToast]);
const error = useCallback((msg, dur) => addToast(msg, 'error', dur), [addToast]);
const warning = useCallback((msg, dur) => addToast(msg, 'warning', dur), [addToast]);
const info = useCallback((msg, dur) => addToast(msg, 'info', dur), [addToast]);
return (
<ToastContext.Provider value={{ addToast, success, error, warning, info }}>
{children}
{/* Punkt 14: Toast container responsive - avoids sidebar overlap on lg screens */}
<div className="fixed top-4 right-4 lg:right-[5rem] z-[9999] flex flex-col gap-2 w-80">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider>
);
}
/* Confirm Modal - replaces browser confirm() */
export function ConfirmModal({ open, title, message, confirmLabel, cancelLabel, onConfirm, onCancel }) {
if (!open) return null;
return (
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && onCancel()}>
<div className="modal-box max-w-sm">
<h3 className="font-bold text-lg">{title || 'Bestätigung'}</h3>
<p className="py-4">{message}</p>
<div className="modal-action">
<button className="btn btn-ghost btn-sm" onClick={onCancel}>{cancelLabel || 'Abbrechen'}</button>
<button className="btn btn-error btn-sm" onClick={onConfirm}>{confirmLabel || 'Löschen'}</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { API_BASE, setCSRFToken, getCSRFToken } from '../utils/api';
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(() => {
try {
const saved = localStorage.getItem('workflow_user');
if (saved) return JSON.parse(saved);
} catch (e) { /* ignore */ }
return null;
});
const [loading, setLoading] = useState(true);
// Verify session on mount - P5: cookie-only auth (no token in localStorage)
useEffect(() => {
fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
.then(res => res.ok ? res.json() : Promise.reject())
.then(data => { setUser(data); localStorage.setItem('workflow_user', JSON.stringify(data)); })
.catch(() => {
setUser(null);
localStorage.removeItem('workflow_user');
setCSRFToken(null);
})
.finally(() => setLoading(false));
}, []);
const login = useCallback(async (email, password) => {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include', // P5: HttpOnly-Cookie only
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Anmeldung fehlgeschlagen');
setUser(data);
localStorage.setItem('workflow_user', JSON.stringify(data));
// P4: Store CSRF token in memory (returned from login response)
if (data.csrfToken) setCSRFToken(data.csrfToken);
return data;
}, []);
const register = useCallback(async (email, password, name = '') => {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name, role: 'user' }),
credentials: 'include', // P5: HttpOnly-Cookie only
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen');
setUser(data);
localStorage.setItem('workflow_user', JSON.stringify(data));
// P4: Store CSRF token in memory (returned from register response)
if (data.csrfToken) setCSRFToken(data.csrfToken);
return data;
}, []);
const logout = useCallback(async () => {
try {
// Bug 2/4: Send CSRF token with logout request
const csrf = getCSRFToken();
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'x-csrf-token': csrf } : {}),
},
credentials: 'include',
});
} catch (e) { /* ignore */ }
setUser(null);
localStorage.removeItem('workflow_user');
setCSRFToken(null);
}, []);
return (
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

41
frontend/src/index.css Normal file
View File

@@ -0,0 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Square checkboxes instead of round */
.checkbox {
border-radius: 0.25rem !important;
}
.checkbox:checked {
border-radius: 0.25rem !important;
}
/* Disable all DaisyUI/Tailwind transitions on buttons and cards to eliminate blur/jank */
.btn,
.btn-ghost,
.btn-primary,
.btn-outline,
.card {
transition: none !important;
animation: none !important;
will-change: auto;
transform: none;
}
/* Explicitly disable DaisyUI button-pop animation */
@keyframes button-pop {
0% { transform: none; }
100% { transform: none; }
}
/* Static skeleton placeholders (no animation) */
.static-skeleton {
background-color: oklch(var(--b3));
border-radius: var(--rounded-box, 1rem);
}
/* Remove hover transitions globally for interactive elements during page load */
.no-transition {
transition: none !important;
animation: none !important;
}

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect, useCallback } from 'react';
import { apiFetch } from '../utils/api';
export default function AuditLogPage() {
const [entries, setEntries] = useState([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
// Punkt 17: Filter-States
const [filterAction, setFilterAction] = useState('');
const [filterUser, setFilterUser] = useState('');
const [filterDateFrom, setFilterDateFrom] = useState('');
const [filterDateTo, setFilterDateTo] = useState('');
const fetchAuditLog = useCallback(async (p = page) => {
try {
let url = `/audit-log?page=${p}&limit=25`;
if (filterAction) url += `&action=${encodeURIComponent(filterAction)}`;
if (filterUser) url += `&user=${encodeURIComponent(filterUser)}`;
if (filterDateFrom) url += `&from=${encodeURIComponent(filterDateFrom)}`;
if (filterDateTo) url += `&to=${encodeURIComponent(filterDateTo)}`;
const res = await apiFetch(url);
if (res.ok) {
const data = await res.json();
setEntries(data.entries || []);
setTotalPages(data.totalPages || 1);
setTotal(data.total || 0);
}
} catch (e) {
console.error('Fehler beim Laden des Audit-Logs:', e);
} finally {
setLoading(false);
}
}, [page, filterAction, filterUser, filterDateFrom, filterDateTo]);
useEffect(() => {
fetchAuditLog();
}, [fetchAuditLog]);
// Reset page when filters change
useEffect(() => {
setPage(1);
setLoading(true);
fetchAuditLog(1);
}, [filterAction, filterUser, filterDateFrom, filterDateTo]);
const goToPage = (p) => {
setPage(p);
setLoading(true);
fetchAuditLog(p);
};
const formatAction = (action) => {
const map = {
login: 'Anmeldung',
logout: 'Abmeldung',
register: 'Registrierung',
// Dot format (new)
'user.create': 'Nutzer erstellt',
'user.update': 'Nutzer bearbeitet',
'user.delete': 'Nutzer gelöscht',
'template.create': 'Vorlage erstellt',
'template.update': 'Vorlage bearbeitet',
'template.delete': 'Vorlage gelöscht',
'task.create': 'Aufgabe erstellt',
'task.update': 'Aufgabe bearbeitet',
'task.delete': 'Aufgabe gelöscht',
'task.add-field': 'Feld hinzugefügt',
'task.delete-field': 'Feld gelöscht',
'ad.create-user': 'AD-Nutzer erstellt',
'ad.create-user-failed': 'AD-Nutzer-Erstellung fehlgeschlagen',
'ad.delete-user': 'AD-Nutzer gelöscht',
// Underscore format (legacy/actual backend)
create_user: 'Nutzer erstellt',
update_user: 'Nutzer bearbeitet',
delete_user: 'Nutzer gelöscht',
create_template: 'Vorlage erstellt',
update_template: 'Vorlage bearbeitet',
delete_template: 'Vorlage gelöscht',
create_task: 'Aufgabe erstellt',
update_task: 'Aufgabe bearbeitet',
delete_task: 'Aufgabe gelöscht',
add_field: 'Feld hinzugefügt',
delete_field: 'Feld gelöscht',
};
return map[action] || action;
};
const formatEntityType = (type) => {
const map = {
user: 'Nutzer',
template: 'Vorlage',
task: 'Aufgabe',
session: 'Sitzung',
ad_user: 'AD-Nutzer',
};
return map[type] || type || '—';
};
if (loading) {
return (
<div className="p-4 md:p-6 space-y-4">
<div className="space-y-2 mb-4">
<div className="static-skeleton h-8 w-32"></div>
<div className="static-skeleton h-4 w-24"></div>
</div>
<div className="static-skeleton h-16 w-full"></div>
<div className="static-skeleton h-40 w-full"></div>
</div>
);
}
return (
<div className="p-4 md:p-6">
<h2 className="text-2xl font-bold mb-4">Audit-Log</h2>
<p className="text-sm opacity-60 mb-4">{total} Einträge</p>
{/* Punkt 17: Filter Controls */}
<div className="flex flex-wrap items-end gap-3 mb-4 p-3 bg-base-200 rounded-lg">
<div className="form-control">
<label className="label py-1"><span className="label-text text-xs">Aktion</span></label>
<select
className="select select-bordered select-sm w-44"
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
>
<option value="">Alle Aktionen</option>
<option value="login">Anmeldung</option>
<option value="logout">Abmeldung</option>
<option value="register">Registrierung</option>
<option value="user.create">Nutzer erstellt</option>
<option value="user.update">Nutzer bearbeitet</option>
<option value="user.delete">Nutzer gelöscht</option>
<option value="template.create">Vorlage erstellt</option>
<option value="template.update">Vorlage bearbeitet</option>
<option value="template.delete">Vorlage gelöscht</option>
<option value="task.create">Aufgabe erstellt</option>
<option value="task.update">Aufgabe bearbeitet</option>
<option value="task.delete">Aufgabe gelöscht</option>
<option value="ad.create-user">AD-Nutzer erstellt</option>
<option value="ad.delete-user">AD-Nutzer gelöscht</option>
</select>
</div>
<div className="form-control">
<label className="label py-1"><span className="label-text text-xs">Nutzer</span></label>
<input
type="text"
placeholder="Nutzer suchen..."
className="input input-bordered input-sm w-40"
value={filterUser}
onChange={(e) => setFilterUser(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label py-1"><span className="label-text text-xs">Von</span></label>
<input
type="date"
className="input input-bordered input-sm w-36"
value={filterDateFrom}
onChange={(e) => setFilterDateFrom(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label py-1"><span className="label-text text-xs">Bis</span></label>
<input
type="date"
className="input input-bordered input-sm w-36"
value={filterDateTo}
onChange={(e) => setFilterDateTo(e.target.value)}
/>
</div>
<button
className="btn btn-ghost btn-sm"
onClick={() => { setFilterAction(''); setFilterUser(''); setFilterDateFrom(''); setFilterDateTo(''); }}
>
Zurücksetzen
</button>
</div>
{entries.length === 0 ? (
<div className="hero min-h-[30vh] bg-base-200 rounded-lg">
<div className="hero-content text-center">
<p className="text-lg opacity-70">Keine Audit-Einträge vorhanden.</p>
</div>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Nutzer</th>
<th>Aktion</th>
<th>Entität</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="hover">
<td className="text-xs whitespace-nowrap">
{new Date(entry.created_at).toLocaleString('de-DE')}
</td>
<td className="text-sm">{entry.user_name || 'System'}</td>
<td>
<span className="badge badge-sm badge-outline">{formatAction(entry.action)}</span>
</td>
<td className="text-sm">
{formatEntityType(entry.entity_type)}
{entry.entity_id ? ` #${entry.entity_id}` : ''}
</td>
<td className="text-xs opacity-70 max-w-xs truncate" title={entry.details}>
{entry.details || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<button
className="btn btn-sm btn-outline"
disabled={page <= 1}
onClick={() => goToPage(page - 1)}
>
« Zurück
</button>
<span className="text-sm opacity-70">
Seite {page} von {totalPages}
</span>
<button
className="btn btn-sm btn-outline"
disabled={page >= totalPages}
onClick={() => goToPage(page + 1)}
>
Weiter »
</button>
</div>
)}
</>
)}
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
export default function NotFound({ onGoHome }) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<div className="card bg-base-200 shadow-lg max-w-md w-full">
<div className="card-body">
<h1 className="text-6xl font-bold text-primary mb-2">404</h1>
<h2 className="text-xl font-semibold mb-2">Seite nicht gefunden</h2>
<p className="text-sm opacity-60 mb-4">
Die angeforderte Seite existiert nicht oder wurde verschoben.
</p>
<button className="btn btn-primary btn-sm" onClick={onGoHome}>
Zurück zum Dashboard
</button>
</div>
</div>
</div>
);
}

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>
);
}

View File

@@ -0,0 +1,191 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from '../components/Toast';
import TemplateCard from '../components/TemplateCard';
import EditorModal from '../components/EditorModal';
import FillModal from '../components/FillModal';
import { apiFetch } from '../utils/api';
export default function TemplatesPage() {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const toast = useToast();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
// Editor modal state
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState(null);
// Fill modal state
const [showFill, setShowFill] = useState(false);
const [fillTemplate, setFillTemplate] = useState(null);
// Confirm modal state
const [confirmState, setConfirmState] = useState({ open: false, id: 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);
}
}, []);
useEffect(() => {
fetchTemplates();
const interval = setInterval(fetchTemplates, 30000);
return () => clearInterval(interval);
}, [fetchTemplates]);
const handleOpenEditor = (tpl = null) => {
setEditingTemplate(tpl);
setShowEditor(true);
};
const handleCloseEditor = () => {
setShowEditor(false);
setEditingTemplate(null);
};
const handleSaveTemplate = async (payload) => {
const url = editingTemplate
? `/templates/${editingTemplate.id}`
: '/templates';
const method = editingTemplate ? 'PUT' : 'POST';
const res = await apiFetch(url, {
method,
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Fehler beim Speichern');
}
await fetchTemplates();
handleCloseEditor();
};
const handleDeleteTemplate = async (id) => {
setConfirmState({ open: true, id });
};
const confirmDelete = async () => {
const id = confirmState.id;
setConfirmState({ open: false, id: null });
try {
const res = await apiFetch(`/templates/${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json();
toast.error(data.error || 'Fehler beim Löschen');
return;
}
toast.success('Vorlage gelöscht.');
await fetchTemplates();
} catch (e) {
toast.error('Netzwerkfehler beim Löschen');
}
};
const handleOpenFill = (tpl) => {
setFillTemplate(tpl);
setShowFill(true);
};
const handleCloseFill = () => {
setShowFill(false);
setFillTemplate(null);
};
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');
}
handleCloseFill();
toast.success('Aufgabe erfolgreich abgesendet!');
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
);
}
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Vorlageneditor</h2>
{isAdmin && (
<button className="btn btn-primary btn-sm" onClick={() => handleOpenEditor(null)}>
<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="M12 4v16m8-8H4" /></svg>
Neue Vorlage erstellen
</button>
)}
</div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
<div className="divide-y divide-base-300">
{templates.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onOpen={handleOpenFill}
onEdit={handleOpenEditor}
onDelete={handleDeleteTemplate}
/>
))}
</div>
</div>
{templates.length === 0 && !isAdmin && (
<div className="hero min-h-[40vh] bg-base-200 rounded-lg">
<div className="hero-content text-center">
<div>
<p className="text-lg opacity-70">Keine Vorlagen vorhanden.</p>
</div>
</div>
</div>
)}
{/* Editor Modal */}
{showEditor && (
<EditorModal
template={editingTemplate}
onSave={handleSaveTemplate}
onClose={handleCloseEditor}
/>
)}
{/* Fill Modal */}
{showFill && fillTemplate && (
<FillModal
template={fillTemplate}
onSubmit={handleSubmitTask}
onClose={handleCloseFill}
/>
)}
{/* Confirm Delete Modal */}
<ConfirmModal
open={confirmState.open}
title="Vorlage löschen"
message="Möchtest du diese Vorlage wirklich löschen? Alle zugehörigen Aufgaben werden ebenfalls entfernt."
confirmLabel="Löschen"
onConfirm={confirmDelete}
onCancel={() => setConfirmState({ open: false, id: null })}
/>
</div>
);
}

View File

@@ -0,0 +1,631 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast, ConfirmModal } from '../components/Toast';
import { apiFetch } from '../utils/api';
const PAGE_SIZE = 20;
export default function UsersPage() {
const { user: currentUser } = useAuth();
const toast = useToast();
const [users, setUsers] = useState([]);
const [totalUsers, setTotalUsers] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [adStatus, setAdStatus] = useState({ configured: false });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
// Modal states
const [addOpen, setAddOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [deletingUser, setDeletingUser] = useState(null);
// Punkt 9: Rollen-Bestätigung
const [roleConfirm, setRoleConfirm] = useState({ open: false, user: null, newRole: '', callback: null });
// Form states
const [formEmail, setFormEmail] = useState('');
const [formName, setFormName] = useState('');
const [formPassword, setFormPassword] = useState('');
const [formRole, setFormRole] = useState('user');
const [formStatus, setFormStatus] = useState('aktiv');
const resetForm = () => {
setFormEmail('');
setFormName('');
setFormPassword('');
setFormRole('user');
setFormStatus('aktiv');
};
// Bug 3: Remove currentPage/search from deps to prevent infinite re-fetches
const fetchUsers = useCallback(async (page, searchQuery) => {
try {
const p = page ?? currentPage;
const sq = searchQuery ?? search;
const searchParam = sq.trim() ? `&search=${encodeURIComponent(sq.trim())}` : '';
const res = await apiFetch(`/users?page=${p}&limit=${PAGE_SIZE}${searchParam}`);
if (!res.ok) throw new Error('Fehler beim Laden der Nutzer');
const data = await res.json();
setUsers(data.users || []);
setTotalUsers(data.total || 0);
setTotalPages(data.totalPages || 1);
} catch (e) {
toast.error(e.message);
} finally {
setLoading(false);
}
}, [toast]); // Only depend on toast
const fetchADStatus = useCallback(async () => {
try {
const res = await apiFetch('/ad/status');
if (res.ok) {
const data = await res.json();
setAdStatus(data);
}
} catch (e) {
// ignore
}
}, []);
useEffect(() => {
fetchUsers();
fetchADStatus();
}, []); // Bug 3: Empty deps - only run once on mount
// Debounced search: only trigger when search actually changes and is non-empty or was previously non-empty
useEffect(() => {
const trimmedSearch = search.trim();
// Avoid double load on initial mount (search is empty and fetchUsers already ran)
if (trimmedSearch === '' && currentPage === 1) return;
const timer = setTimeout(() => {
setCurrentPage(1);
setLoading(true);
fetchUsers(1, search);
}, 300);
return () => clearTimeout(timer);
}, [search]);
const goToPage = (page) => {
setCurrentPage(page);
setLoading(true);
fetchUsers(page, search);
};
const openAdd = () => {
resetForm();
setAddOpen(true);
};
const openEdit = (user) => {
setEditingUser(user);
setFormEmail(user.email || '');
setFormName(user.name || '');
setFormPassword('');
setFormRole(user.role);
setFormStatus(user.status || 'aktiv');
setEditOpen(true);
};
const openDelete = (user) => {
if (user.source === 'ad') {
toast.addToast('AD-Nutzer können nicht gelöscht werden.', 'error');
return;
}
setDeletingUser(user);
setDeleteOpen(true);
};
const handleAdd = async () => {
if (!formEmail.trim() || !formPassword.trim()) return;
try {
const res = await apiFetch('/users', {
method: 'POST',
body: JSON.stringify({
email: formEmail.trim(),
password: formPassword.trim(),
name: formName.trim(),
role: formRole,
status: formStatus,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler beim Anlegen');
toast.success('Nutzer erfolgreich angelegt');
setAddOpen(false);
resetForm();
fetchUsers();
} catch (e) {
toast.addToast(e.message, 'error');
}
};
const handleEdit = async () => {
if (!editingUser) return;
// Punkt 9: Rollen-Bestätigung bei Admin-Änderung
const roleChanged = formRole !== editingUser.role;
const involvesAdmin = formRole === 'admin' || editingUser.role === 'admin';
if (roleChanged && involvesAdmin) {
return new Promise((resolve) => {
setRoleConfirm({
open: true,
user: editingUser,
newRole: formRole,
callback: async () => {
setRoleConfirm({ open: false, user: null, newRole: '', callback: null });
await doHandleEdit();
resolve();
},
});
});
}
await doHandleEdit();
};
const doHandleEdit = async () => {
if (!editingUser) return;
try {
const body = {};
if (editingUser.source === 'ad') {
// AD users: only role and status
body.role = formRole;
body.status = formStatus;
} else {
// Local users: full edit
if (!formEmail.trim()) return;
body.email = formEmail.trim();
body.name = formName.trim();
body.role = formRole;
body.status = formStatus;
if (formPassword.trim()) body.password = formPassword.trim();
}
const res = await apiFetch(`/users/${editingUser.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler beim Bearbeiten');
toast.success('Nutzer erfolgreich aktualisiert');
setEditOpen(false);
setEditingUser(null);
fetchUsers();
} catch (e) {
toast.addToast(e.message, 'error');
}
};
const handleDelete = async () => {
if (!deletingUser) return;
try {
const res = await apiFetch(`/users/${deletingUser.id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler beim Löschen');
toast.success('Nutzer erfolgreich gelöscht');
setDeleteOpen(false);
setDeletingUser(null);
fetchUsers();
} catch (e) {
toast.addToast(e.message, 'error');
}
};
// Backend filters by status (active only without search, all with search)
const localUsers = users.filter(u => u.source !== 'ad');
const adUsers = users.filter(u => u.source === 'ad');
const roleBadgeClass = (role) => {
switch (role) {
case 'admin': return 'badge-error';
case 'user': return 'badge-primary';
default: return 'badge-ghost';
}
};
const statusBadgeClass = (status) => {
switch (status) {
case 'aktiv': return 'badge-success';
case 'inaktiv': return 'badge-warning';
default: return 'badge-ghost';
}
};
const getInitials = (user) => {
if (user.name) {
const parts = user.name.trim().split(/\s+/);
return parts.map(p => p[0]).join('').toUpperCase().slice(0, 2);
}
return (user.email || '?')[0].toUpperCase();
};
const renderUserCard = (u) => (
<div key={u.id} className="card bg-base-100 border border-base-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 no-transition">
<div className="card-body p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="avatar placeholder">
<div className={`rounded-full w-10 h-10 flex items-center justify-center ${u.source === 'ad' ? 'bg-secondary text-secondary-content' : 'bg-primary text-primary-content'}`}>
<span className="text-sm font-bold">{getInitials(u)}</span>
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm truncate">{u.name || u.email}</p>
<p className="text-xs opacity-60 truncate">{u.email}</p>
{u.username && (
<p className="text-xs opacity-40 truncate">Anmeldename: {u.username}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
className="btn btn-ghost btn-xs btn-square"
onClick={() => openEdit(u)}
title="Bearbeiten"
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
{u.source !== 'ad' && u.id !== currentUser?.id && (
<button
className="btn btn-ghost btn-xs btn-square text-error hover:bg-error hover:text-error-content"
onClick={() => openDelete(u)}
title="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>
)}
{u.source === 'ad' && (
<span className="badge badge-sm badge-secondary">AD</span>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-2">
<span className={`badge badge-sm ${roleBadgeClass(u.role)}`}>
{u.role}
</span>
<span className={`badge badge-sm ${statusBadgeClass(u.status || 'aktiv')}`}>
{u.status || 'aktiv'}
</span>
</div>
</div>
</div>
);
if (loading) {
return (
<div className="p-6 space-y-4">
{/* Static skeleton header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div className="space-y-2">
<div className="static-skeleton h-8 w-48"></div>
<div className="static-skeleton h-4 w-32"></div>
</div>
<div className="static-skeleton h-9 w-36 rounded-btn"></div>
</div>
{/* Static skeleton search */}
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn mb-4"></div>
{/* Static skeleton cards */}
<div className="static-skeleton h-40 w-full mb-4"></div>
<div className="static-skeleton h-40 w-full"></div>
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold">Nutzerverwaltung</h1>
<p className="text-sm opacity-60">{totalUsers} Nutzer insgesamt</p>
</div>
<button className="btn btn-primary btn-sm" onClick={openAdd}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
Nutzer hinzufügen
</button>
</div>
{/* Search */}
<div className="flex items-center gap-3 mb-4">
<div className="form-control w-full max-w-sm">
<div className="input-group">
<input
type="text"
placeholder="Suche nach Name, E-Mail, Rolle oder Anmeldename..."
className="input input-bordered input-sm w-full"
value={search}
onChange={(e) => { setSearch(e.target.value); }}
/>
</div>
</div>
{search && (
<button className="btn btn-ghost btn-sm" onClick={() => { setSearch(''); }}>
Filter zurücksetzen
</button>
)}
</div>
{/* Local Users Card */}
<div className="card bg-base-200 mb-4">
<div className="card-body p-4">
<div className="flex items-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
<h2 className="card-title text-base">Lokale Nutzer</h2>
<span className="badge badge-sm badge-primary">{localUsers.length}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{localUsers.length === 0 ? (
<div className="col-span-full text-center py-6 opacity-50 text-sm">
Keine lokalen Nutzer gefunden.
</div>
) : (
localUsers.map(renderUserCard)
)}
</div>
</div>
</div>
{/* AD Users Card */}
{adStatus.configured && (
<div className="card bg-base-200 mb-4">
<div className="card-body p-4">
<div className="flex items-center gap-2 mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
<h2 className="card-title text-base">Active Directory Nutzer</h2>
<span className="badge badge-sm badge-secondary">{adUsers.length}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{adUsers.length === 0 ? (
<div className="col-span-full text-center py-6 opacity-50 text-sm">
Keine AD-Nutzer gefunden. Die Synchronisation läuft automatisch.
</div>
) : (
adUsers.map(renderUserCard)
)}
</div>
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<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} ({totalUsers} Nutzer)
</span>
<button
className="btn btn-sm btn-outline"
disabled={currentPage >= totalPages}
onClick={() => goToPage(currentPage + 1)}
>
Weiter »
</button>
</div>
)}
{/* Add Modal */}
<dialog className={`modal ${addOpen ? 'modal-open' : ''}`}>
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">Neuer Nutzer</h3>
<div className="flex flex-col gap-4">
<div className="form-control">
<label className="label"><span className="label-text font-medium">Name</span></label>
<input
type="text"
placeholder="z.B. Max Mustermann"
className="input input-bordered input-sm w-full"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
<input
type="text"
inputMode="email"
placeholder="z.B. max@beispiel.de"
className="input input-bordered input-sm w-full"
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Passwort</span></label>
<input
type="password"
placeholder="Passwort vergeben"
className="input input-bordered input-sm w-full"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Rolle</span></label>
<select
className="select select-bordered select-sm w-full"
value={formRole}
onChange={(e) => setFormRole(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Status</span></label>
<select
className="select select-bordered select-sm w-full"
value={formStatus}
onChange={(e) => setFormStatus(e.target.value)}
>
<option value="aktiv">Aktiv</option>
<option value="inaktiv">Inaktiv</option>
</select>
</div>
</div>
<div className="modal-action">
<button className="btn btn-sm btn-ghost" onClick={() => { setAddOpen(false); resetForm(); }}>Abbrechen</button>
<button
className="btn btn-sm btn-primary"
onClick={handleAdd}
disabled={!formEmail.trim() || !formPassword.trim()}
>
Speichern
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={() => { setAddOpen(false); resetForm(); }}>close</button></form>
</dialog>
{/* Edit Modal */}
<dialog className={`modal ${editOpen ? 'modal-open' : ''}`}>
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">
{editingUser?.source === 'ad' ? 'AD-Nutzer bearbeiten' : 'Nutzer bearbeiten'}
</h3>
<p className="text-sm opacity-60 mb-4">
{editingUser?.source === 'ad'
? <>Ändere Rolle und Status von <strong>{editingUser?.name || editingUser?.email}</strong>. Name, E-Mail und Passwort werden aus dem AD synchronisiert.</>
: <>Ändere die Daten von <strong>{editingUser?.name || editingUser?.email}</strong>.</>
}
</p>
<div className="flex flex-col gap-4">
{editingUser?.source === 'ad' ? (
<>
{/* AD user: show name/email as read-only info */}
<div className="form-control">
<label className="label"><span className="label-text font-medium">Name</span></label>
<input
type="text"
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
value={formName}
disabled
/>
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
<input
type="text"
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
value={formEmail}
disabled
/>
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
</div>
{editingUser?.username && (
<div className="form-control">
<label className="label"><span className="label-text font-medium">Anmeldename</span></label>
<input
type="text"
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
value={editingUser.username}
disabled
/>
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
</div>
)}
</>
) : (
<>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Name</span></label>
<input
type="text"
className="input input-bordered input-sm w-full"
value={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
<input
type="text"
inputMode="email"
className="input input-bordered input-sm w-full"
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Neues Passwort</span></label>
<input
type="password"
placeholder="Leer lassen, um nicht zu ändern"
className="input input-bordered input-sm w-full"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
</>
)}
<div className="form-control">
<label className="label"><span className="label-text font-medium">Rolle</span></label>
<select
className="select select-bordered select-sm w-full"
value={formRole}
onChange={(e) => setFormRole(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-control">
<label className="label"><span className="label-text font-medium">Status</span></label>
<select
className="select select-bordered select-sm w-full"
value={formStatus}
onChange={(e) => setFormStatus(e.target.value)}
>
<option value="aktiv">Aktiv</option>
<option value="inaktiv">Inaktiv</option>
</select>
</div>
</div>
<div className="modal-action">
<button className="btn btn-sm btn-ghost" onClick={() => { setEditOpen(false); setEditingUser(null); }}>Abbrechen</button>
<button
className="btn btn-sm btn-primary"
onClick={handleEdit}
>
Speichern
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={() => { setEditOpen(false); setEditingUser(null); }}>close</button></form>
</dialog>
{/* Delete Modal */}
<dialog className={`modal ${deleteOpen ? 'modal-open' : ''}`}>
<div className="modal-box">
<h3 className="font-bold text-lg">Nutzer löschen</h3>
<p className="py-4">
Bist du sicher, dass du <strong>{deletingUser?.name || deletingUser?.email}</strong> löschen möchtest? Das kann nicht rückgängig gemacht werden.
</p>
<div className="modal-action">
<button className="btn btn-sm btn-ghost" onClick={() => { setDeleteOpen(false); setDeletingUser(null); }}>Abbrechen</button>
<button className="btn btn-sm btn-error" onClick={handleDelete}>Löschen</button>
</div>
</div>
<form method="dialog" className="modal-backdrop"><button onClick={() => { setDeleteOpen(false); setDeletingUser(null); }}>close</button></form>
</dialog>
{/* Punkt 9: Rollen-Bestätigung Modal */}
<ConfirmModal
open={roleConfirm.open}
title="Rolle ändern"
message={`Möchtest du die Rolle von ${roleConfirm.user?.name || roleConfirm.user?.email} wirklich zu "${roleConfirm.newRole === 'admin' ? 'Admin' : 'User'}" ändern?${roleConfirm.newRole === 'admin' ? ' Admins haben vollen Zugriff auf alle Funktionen.' : ' Der Nutzer verliert administrative Berechtigungen.'}`}
confirmLabel="Bestätigen"
onConfirm={roleConfirm.callback}
onCancel={() => setRoleConfirm({ open: false, user: null, newRole: '', callback: null })}
/>
</div>
);
}

65
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,65 @@
// Zentrale API-Konfiguration - über Env-Variable konfigurierbar (VITE_API_BASE)
// Im Single-Container-Modus: Frontend und Backend auf gleichem Port → relative URL
export const API_BASE = import.meta.env.VITE_API_BASE || '/api';
export const FILE_BASE = import.meta.env.VITE_API_BASE
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
: '';
// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens)
let csrfToken = null;
export function setCSRFToken(token) {
csrfToken = token;
}
export function getCSRFToken() {
return csrfToken;
}
// P5: Auth relies on HttpOnly cookie only - no token in localStorage
export function getAuthHeaders(includeContentType = true) {
const headers = {};
if (includeContentType) headers['Content-Type'] = 'application/json';
// P4: Attach CSRF token for state-changing requests
if (csrfToken) headers['x-csrf-token'] = csrfToken;
return headers;
}
// Punkt 11: Zentrales Error-Handling mit automatischer 401-Behandlung
// und Toast-Benachrichtigung für Serverfehler
let globalErrorHandler = null;
export function setGlobalErrorHandler(handler) {
globalErrorHandler = handler;
}
export async function apiFetch(path, options = {}) {
const isFormData = options.body instanceof FormData;
const headers = { ...getAuthHeaders(!isFormData), ...(options.headers || {}) };
let res;
try {
// Punkt 8: credentials: 'include' for HttpOnly-Cookie auth
res = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include' });
} catch (err) {
// Netzwerkfehler
if (globalErrorHandler) globalErrorHandler('network', 'Netzwerkfehler. Bitte Verbindung prüfen.');
throw err;
}
if (res.status === 401) {
localStorage.removeItem('workflow_user');
setCSRFToken(null);
window.location.reload();
throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
}
if (res.status === 429) {
if (globalErrorHandler) globalErrorHandler('rateLimit', 'Zu viele Anfragen. Bitte später erneut versuchen.');
}
if (res.status >= 500) {
if (globalErrorHandler) globalErrorHandler('server', 'Serverfehler. Bitte später erneut versuchen.');
}
return res;
}

View File

@@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require('daisyui')],
daisyui: {
themes: ["light", "dark"],
},
}

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true,
},
});