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:
1
frontend/.dockerignore
Normal file
1
frontend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal 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
14
frontend/Dockerfile
Normal 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
19
frontend/index.html
Normal 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
2176
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
143
frontend/src/App.jsx
Normal file
143
frontend/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/AuthModal.jsx
Normal file
145
frontend/src/components/AuthModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
889
frontend/src/components/EditorModal.jsx
Normal file
889
frontend/src/components/EditorModal.jsx
Normal 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. "Mustermann, Thomas (Amt Leezen)"). 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/ErrorBoundary.jsx
Normal file
38
frontend/src/components/ErrorBoundary.jsx
Normal 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;
|
||||
}
|
||||
}
|
||||
432
frontend/src/components/FillModal.jsx
Normal file
432
frontend/src/components/FillModal.jsx
Normal 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 "{step.email_source_fields}" und "{step.email_domain}" 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/Sidebar.jsx
Normal file
107
frontend/src/components/Sidebar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1226
frontend/src/components/TaskModal.jsx
Normal file
1226
frontend/src/components/TaskModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/src/components/TemplateCard.jsx
Normal file
39
frontend/src/components/TemplateCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/Toast.jsx
Normal file
100
frontend/src/components/Toast.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/context/AuthContext.jsx
Normal file
90
frontend/src/context/AuthContext.jsx
Normal 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
41
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
248
frontend/src/pages/AuditLogPage.jsx
Normal file
248
frontend/src/pages/AuditLogPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
259
frontend/src/pages/Dashboard.jsx
Normal file
259
frontend/src/pages/Dashboard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
frontend/src/pages/NotFound.jsx
Normal file
20
frontend/src/pages/NotFound.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
268
frontend/src/pages/TasksPage.jsx
Normal file
268
frontend/src/pages/TasksPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/pages/TemplatesPage.jsx
Normal file
191
frontend/src/pages/TemplatesPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
631
frontend/src/pages/UsersPage.jsx
Normal file
631
frontend/src/pages/UsersPage.jsx
Normal 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
65
frontend/src/utils/api.js
Normal 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;
|
||||
}
|
||||
14
frontend/tailwind.config.js
Normal file
14
frontend/tailwind.config.js
Normal 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
10
frontend/vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user