DEV1.0: Initial commit - Workflow Portal with security fixes

- Backend: Express.js + PostgreSQL/SQLite with LDAP/AD integration
- Frontend: React 18 + Vite + TailwindCSS/DaisyUI
- Security fixes applied (2026-07 + 2026-08):
  - LDAP injection prevention, CSRF protection, HttpOnly cookies
  - Session hashing (SHA-256), account lockout, rate limiting
  - Input validation (zod), file upload security, CSP/HSTS headers
  - V3: express-rate-limit updated (ip-address SSRF fix)
  - V4: postcss updated (nanoid DoS fix)
  - V5: Rate-limit on /health endpoint
  - V6: Session rotation on login (session fixation prevention)
  - V9: Task values array limit (DoS prevention)
  - V10: Frontend XSS audit completed
- Docker: Multi-stage build, non-root user, PostgreSQL + backup service
This commit is contained in:
Kühn
2026-08-24 09:45:28 +02:00
commit 6be1791c62
103 changed files with 12253 additions and 0 deletions

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# ============ Dependencies ============
**/node_modules
**/package-lock.json
# ============ Build Artifacts ============
**/dist
frontend/dist
# ============ Data & Secrets ============
**/data
**/.env
**/.env.*
!.env.example
# ============ Git ============
.git
.gitignore
# ============ IDE ============
.vscode
.idea
*.swp
*.swo
# ============ Docker ============
Dockerfile
docker-compose.yml
.dockerignore
# ============ Misc ============
**/*.md
**/.DS_Store
**/Thumbs.db

49
.env.example Normal file
View File

@@ -0,0 +1,49 @@
# ============================================================
# Workflow Portal - Environment Configuration
# ============================================================
# Kopiere diese Datei zu .env und passe die Werte an.
# Alle Werte in <> müssen ausgefüllt werden.
# Werte mit Defaults können auskommentiert oder belassen werden.
# ============================================================
# ============ LDAP / Active Directory ============
LDAP_SERVER=PIDC02.seatle.intra
LDAP_PORT=636
LDAP_SEARCH_BASE=<z.B. DC=SEATLE,DC=INTRA>
LDAP_DOMAIN=SEATLE
LDAP_IGNORE_CERT_ERRORS=true
LDAP_BIND_USER=<z.B. svc_workflow@seatle.intra>
LDAP_BIND_PASSWORD=<LDAP-Service-Account-Passwort>
LDAP_SYNC_INTERVAL=300000
LDAP_FILTER=
LDAP_ATTRIBUTES=mail,displayName,memberOf,distinguishedName,sAMAccountName
LDAP_CREATE_OU=<z.B. OU=Users,OU=SEATLE,DC=SEATLE,DC=INTRA>
LDAP_UPN_SUFFIX=<z.B. seatle.intra>
# ============ Admin Account ============
ADMIN_EMAIL=admin@workflow.local
ADMIN_INIT_PASSWORD=<Admin-Initial-Passwort, min. 8 Zeichen mit Groß-/Kleinbuchstaben + Zahl>
# ============ Server ============
PORT=5000
NODE_ENV=production
CORS_ORIGIN=http://localhost:5000
# ============ PostgreSQL Database ============
POSTGRES_DB=workflow
POSTGRES_USER=workflow
POSTGRES_PASSWORD=<Sicheres Datenbank-Passwort>
# DATABASE_URL wird automatisch aus den Werten oben generiert:
# postgresql://workflow:<POSTGRES_PASSWORD>@db:5432/workflow
# ============ Security ============
SESSION_MAX_PER_USER=5
SESSION_TTL_HOURS=168
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCKOUT_MINUTES=15
BODY_LIMIT=1mb
UPLOAD_MAX_MB=10
# ============ DB Backup ============
BACKUP_INTERVAL_HOURS=6
BACKUP_RETENTION_DAYS=30

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Secrets & Environment
.env
.env.local
.env.*.local
**/.env
**/.env.local
# Dependencies
node_modules/
**/node_modules/
# Database & Data
backend/data/
*.db
*.db-journal
# Build artifacts
frontend/dist/
dist/
build/
# Logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:20-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM node:20-alpine
RUN apk add --no-cache python3 make g++ openssl wget su-exec
# Create non-root user (Punkt 6: Non-Root Container)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY backend/package*.json ./
RUN npm install
COPY backend/ ./
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
# Create data directory
RUN mkdir -p /app/data/uploads
EXPOSE 5000
# Health check (Punkt 7: Docker Healthcheck)
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \
CMD wget -qO- http://localhost:5000/health || exit 1
# Entrypoint: fix permissions on mounted volumes, then run as non-root user
ENTRYPOINT ["sh", "-c", "chown -R appuser:appgroup /app/data 2>/dev/null; exec su-exec appuser node server.js"]

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
data

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine
RUN apk add --no-cache python3 make g++ openssl
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5000
CMD ["node", "server.js"]

18
backend/auditLog.js Normal file
View File

@@ -0,0 +1,18 @@
/**
* Audit logging module (async).
*
* Punkt 13: Now stores ip_address and user_agent.
* Fire-and-forget: errors are logged but don't block the caller.
*/
const db = require('./db');
function auditLog(userId, action, entityType, entityId, details, req) {
const ip = req?.ip || req?.headers?.['x-forwarded-for'] || null;
const userAgent = req?.headers?.['user-agent'] || null;
db.prepare('INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(userId || null, action, entityType || null, entityId || null, details || null, ip, userAgent)
.catch(err => console.error('Audit log error:', err.message));
}
module.exports = { auditLog };

154
backend/db.js Normal file
View File

@@ -0,0 +1,154 @@
/**
* Database abstraction layer (async).
*
* Punkt 4: PostgreSQL for production, SQLite fallback for development.
* Both modes expose the SAME async API: db.prepare(sql).run/get/all() return Promises.
*
* - If DATABASE_URL starts with 'postgresql://' → PostgreSQL (pg)
* - Otherwise → SQLite (better-sqlite3, wrapped in Promises for unified async API)
*/
const path = require('path');
const DATABASE_URL = process.env.DATABASE_URL || '';
const usePostgres = DATABASE_URL.startsWith('postgresql://') || DATABASE_URL.startsWith('postgres://');
// Helper: convert SQLite ? placeholders to PostgreSQL $1, $2, etc.
function convertPlaceholders(sql) {
let idx = 0;
return sql.replace(/\?/g, () => { idx++; return '$' + idx; });
}
let db;
if (usePostgres) {
// ============ PostgreSQL mode (production) ============
const { Pool } = require('pg');
const pool = new Pool({
connectionString: DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 10000,
});
pool.on('error', (err) => {
console.error('[DB] PostgreSQL Pool-Fehler:', err.message);
});
console.log('[DB] PostgreSQL-Verbindung hergestellt (Production-Modus).');
db = {
_pool: pool,
_type: 'postgres',
prepare(sql) {
const pgSql = convertPlaceholders(sql);
return {
run: (...params) => {
// For INSERT statements, append RETURNING id to get the generated ID
const isInsert = pgSql.trim().toUpperCase().startsWith('INSERT');
const finalSql = isInsert && !pgSql.toUpperCase().includes('RETURNING')
? pgSql.replace(/;?\s*$/, ' RETURNING id')
: pgSql;
return pool.query(finalSql, params).then(result => ({
changes: result.rowCount,
lastInsertRowid: result.rows[0]?.id || null,
}));
},
get: (...params) => pool.query(pgSql, params).then(result => result.rows[0] || null),
all: (...params) => pool.query(pgSql, params).then(result => result.rows),
};
},
exec(sql) {
return pool.query(sql);
},
pragma(_str) {
return Promise.resolve({});
},
transaction(fn) {
return async (...args) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const txDb = {
prepare(sql) {
const pgSql = convertPlaceholders(sql);
return {
run: (...params) => client.query(pgSql, params).then(result => ({
changes: result.rowCount,
lastInsertRowid: result.rows[0]?.id || null,
})),
get: (...params) => client.query(pgSql, params).then(result => result.rows[0] || null),
all: (...params) => client.query(pgSql, params).then(result => result.rows),
};
},
exec: (sql) => client.query(sql),
pragma: () => Promise.resolve({}),
};
const result = await fn.call(txDb, ...args);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
};
},
close() {
return pool.end();
},
};
} else {
// ============ SQLite mode (development) ============
// Wrapped in Promises so the API is identical to PostgreSQL (async)
const Database = require('better-sqlite3');
const dbPath = path.join(__dirname, 'data', 'workflow.db');
const sqliteDb = new Database(dbPath);
sqliteDb.pragma('journal_mode = WAL');
sqliteDb.pragma('foreign_keys = ON');
console.log('[DB] SQLite-Datenbank verbunden (better-sqlite3, WAL-Modus, async-Wrapper).');
db = {
_type: 'sqlite',
prepare(sql) {
const stmt = sqliteDb.prepare(sql);
return {
run: (...params) => Promise.resolve(stmt.run(...params)),
get: (...params) => Promise.resolve(stmt.get(...params)),
all: (...params) => Promise.resolve(stmt.all(...params)),
};
},
exec(sql) {
sqliteDb.exec(sql);
return Promise.resolve();
},
pragma(str) {
sqliteDb.pragma(str);
return Promise.resolve({});
},
transaction(fn) {
const tx = sqliteDb.transaction(fn);
return (...args) => Promise.resolve(tx(...args));
},
close() {
sqliteDb.close();
return Promise.resolve();
},
};
}
module.exports = db;

444
backend/ldapOperations.js Normal file
View File

@@ -0,0 +1,444 @@
const { Client, Attribute, Change } = require('ldapts');
/**
* LDAP Operations Module (ldapts)
*
* Provides functions for browsing the AD tree and creating users in Active Directory.
* Uses ldapts (maintained) instead of deprecated ldapjs.
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally in all functions
*/
const LDAP_SERVER = process.env.LDAP_SERVER || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_CREATE_OU = process.env.LDAP_CREATE_OU || '';
const LDAP_UPN_SUFFIX = process.env.LDAP_UPN_SUFFIX || '';
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
/**
* Normalize ldapts attribute values.
* ldapts may return attributes as arrays; this helper extracts single values.
*/
function attr(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val[0] || '';
if (val !== undefined && val !== null) return val;
return '';
}
function attrArray(entry, key) {
const val = entry[key];
if (Array.isArray(val)) return val;
if (val !== undefined && val !== null) return [val];
return [];
}
/**
* Create and bind an LDAP client using ldapts.
* Punkt 7: Returns a bound client; caller must call client.unbind() in finally block.
*/
async function createClient() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
return client;
} catch (err) {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup on bind failure
throw new Error('LDAP Bind fehlgeschlagen: ' + (err.message || err));
}
}
/**
* Browse the AD tree and return OUs under the configured base or a given path.
* Returns a hierarchical tree structure.
*/
async function browseOUTree(searchBase) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
const base = searchBase || LDAP_SEARCH_BASE;
try {
const { searchEntries } = await client.search(base, {
filter: '(objectClass=organizationalUnit)',
scope: 'one',
attributes: ['distinguishedName', 'name'],
sizeLimit: 500,
});
const ous = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
name: attr(entry, 'name') || '',
}));
// Recursively fetch children for each OU
const results = [];
for (const ou of ous) {
let children = [];
try {
children = await browseOUTree(ou.dn);
} catch (e) {
// Ignore errors for individual OU children
}
results.push({
dn: ou.dn,
name: ou.name,
children: children,
});
}
return results;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Escape special characters in LDAP distinguished names.
*/
function escapeLDAPDN(str) {
return str.replace(/[,+"\\<>;]/g, '\\$&');
}
/**
* Replace German umlauts and ß for sAMAccountName compatibility.
*/
function replaceUmlauts(str) {
return str
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss');
}
/**
* Create a user in Active Directory.
* Punkt 7: Proper client cleanup with try/finally
*/
async function createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c }) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
if (!ou || !username || !password) {
throw new Error('OU, Anmeldename und Passwort sind erforderlich.');
}
if (!vorname || !nachname) {
throw new Error('Vorname und Nachname sind erforderlich, um einen AD-Benutzer anzulegen.');
}
const client = await createClient();
try {
// CN format: Nachname, Vorname (as per AD convention)
const cnValue = nachname + ', ' + vorname;
const escapedCN = escapeLDAPDN(cnValue);
const dn = 'CN=' + escapedCN + ',' + ou;
// Build UPN
const upnSuffix = LDAP_UPN_SUFFIX || LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN.toLowerCase() + '.intra';
const userPrincipalName = username + '@' + upnSuffix;
// sAMAccountName: max 20 chars
let sAMAccountName = username;
if (vorname && nachname) {
sAMAccountName = replaceUmlauts(nachname + vorname.charAt(0)).replace(/[^a-zA-Z0-9]/g, '');
}
sAMAccountName = sAMAccountName.substring(0, 20);
// userAccountControl: 514 = NORMAL_ACCOUNT + ACCOUNTDISABLE
const userAccountControl = 514;
const effectiveDisplayName = displayName || (nachname + ', ' + vorname);
const entry = {
objectClass: ['top', 'person', 'organizationalPerson', 'user'],
cn: cnValue,
sn: nachname,
givenName: vorname,
displayName: effectiveDisplayName,
sAMAccountName: sAMAccountName,
userPrincipalName: userPrincipalName,
userAccountControl: userAccountControl,
};
if (email) entry.mail = email;
if (department) entry.department = department;
if (telefon) entry.telephoneNumber = telefon;
if (titel) entry.title = titel;
if (physicalDeliveryOfficeName) entry.physicalDeliveryOfficeName = physicalDeliveryOfficeName;
if (company) entry.company = company;
if (description) entry.description = description;
if (wWWHomePage) entry.wWWHomePage = wWWHomePage;
if (streetAddress) entry.streetAddress = streetAddress;
if (postOfficeBox) entry.postOfficeBox = postOfficeBox;
if (l) entry.l = l;
if (st) entry.st = st;
if (postalCode) entry.postalCode = postalCode;
// c (country) must be a 2-letter ISO-3166 code
if (c) {
const countryCode = String(c).trim().toUpperCase().substring(0, 2);
if (countryCode.length === 2 && /^[A-Z]{2}$/.test(countryCode)) {
entry.c = countryCode;
}
}
// Step 1: Create user as DISABLED
try {
await client.add(dn, entry);
} catch (err) {
if (err.message && err.message.includes('ENTRY_ALREADY_EXISTS')) {
throw new Error('Ein Benutzer mit diesem Namen existiert bereits an dieser Stelle im AD.');
}
if (err.message && err.message.includes('Constraint Violation')) {
console.error('[LDAP] Constraint Violation:', err.message, 'Entry:', JSON.stringify(entry, null, 2));
throw new Error('Constraint Violation: Ein Pflichtfeld fehlt oder enthält einen ungültigen Wert. Bitte Vorname, Nachname und Anmeldename prüfen. Das Land-Feld (c) muss ein 2-Buchstaben-Code sein (z.B. DE).');
}
throw new Error('Fehler beim Erstellen: ' + (err.message || err));
}
console.log('[LDAP] Benutzer erstellt (deaktiviert):', dn);
// Step 2: Set the password
const unicodePwd = Buffer.from('"' + password + '"', 'utf16le');
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'unicodePwd',
values: [unicodePwd],
}),
}),
]);
} catch (pwdErr) {
console.warn('[LDAP] Passwort konnte nicht gesetzt werden (Benutzer wurde deaktiviert erstellt):', pwdErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt (deaktiviert), aber Passwort konnte nicht gesetzt werden: ' + pwdErr.message,
};
}
console.log('[LDAP] Passwort gesetzt für:', dn);
// Step 3: Enable the account (userAccountControl: 512 = NORMAL_ACCOUNT, enabled)
try {
await client.modify(dn, [
new Change({
operation: 'replace',
modification: new Attribute({
type: 'userAccountControl',
values: [512],
}),
}),
]);
} catch (enableErr) {
console.warn('[LDAP] Konto konnte nicht aktiviert werden (Benutzer wurde mit Passwort erstellt):', enableErr.message);
return {
dn: dn,
username: username,
warning: 'Benutzer erstellt und Passwort gesetzt, aber Konto konnte nicht aktiviert werden: ' + enableErr.message,
};
}
console.log('[LDAP] Konto aktiviert für:', dn);
// P2: Clear plaintext password from memory after use
password = null;
return { dn: dn, username: username };
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Check if a user exists in AD by sAMAccountName.
* Punkt 7: Proper client cleanup
*/
async function checkADUserExists(username, sAMAccountName) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
// P1: LDAP-Injection prevention - sanitize username and samName before building filter
const escapeLDAPFilter = (str) => String(str || '').replace(/[*()\\\x00]/g, '\\$&');
const safeSamName = escapeLDAPFilter(sAMAccountName || username);
const safeUsername = escapeLDAPFilter(username);
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(|(sAMAccountName=' + safeSamName + ')(userPrincipalName=' + safeUsername + '@*))',
scope: 'sub',
attributes: ['distinguishedName', 'sAMAccountName', 'displayName', 'userPrincipalName'],
sizeLimit: 100,
});
if (searchEntries.length > 0) {
const entry = searchEntries[0];
return {
distinguishedName: attr(entry, 'distinguishedName') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
displayName: attr(entry, 'displayName') || '',
userPrincipalName: attr(entry, 'userPrincipalName') || '',
};
}
return null;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Delete a user from Active Directory by DN.
* Punkt 7: Proper client cleanup
*/
async function deleteADUser(dn) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
await client.del(dn);
console.log('[LDAP] Benutzer gelöscht (Rollback):', dn);
} catch (err) {
console.error('[LDAP] Fehler beim Löschen des Benutzers (Rollback):', err.message);
throw err;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Search for AD groups/security principals matching a query.
* Returns all groups (no GRP_ filter - used for security group search).
* Punkt 7: Proper client cleanup
*/
async function searchADGroups(query) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
const escapedQuery = query.replace(/[()*\\]/g, '\\$&');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: `(&(objectClass=group)(|(cn=*${escapedQuery}*)(displayName=*${escapedQuery}*)(sAMAccountName=*${escapedQuery}*)))`,
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'groupType'],
sizeLimit: 100,
});
return searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
/**
* Add a user to one or more AD groups.
* Punkt 7: Proper client cleanup
*/
async function addUserToGroups(userDN, groupDNs) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
const results = [];
try {
for (const groupDN of groupDNs) {
try {
await client.modify(groupDN, [
new Change({
operation: 'add',
modification: new Attribute({
type: 'member',
values: [userDN],
}),
}),
]);
results.push({ dn: groupDN, status: 'added' });
} catch (err) {
if (err.message && err.message.includes('already exists')) {
results.push({ dn: groupDN, status: 'already_member' });
} else {
results.push({ dn: groupDN, status: 'error', error: err.message });
}
}
}
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
return results;
}
/**
* Browse all AD groups under the configured search base.
* Returns only GRP_ groups for static display.
* Punkt 7: Proper client cleanup
*/
async function browseADGroups() {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
const client = await createClient();
try {
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: '(&(objectClass=group)(cn=GRP_*))',
scope: 'sub',
attributes: ['distinguishedName', 'cn', 'displayName', 'sAMAccountName', 'description', 'memberOf'],
sizeLimit: 500,
});
const groups = searchEntries.map(entry => ({
dn: attr(entry, 'distinguishedName') || '',
cn: attr(entry, 'cn') || '',
displayName: attr(entry, 'displayName') || attr(entry, 'cn') || '',
sAMAccountName: attr(entry, 'sAMAccountName') || '',
description: attr(entry, 'description') || '',
}));
// Sort groups by displayName/cn for easier browsing
groups.sort((a, b) => (a.displayName || a.cn).localeCompare(b.displayName || b.cn));
return groups;
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, browseOUTree, createADUser, checkADUserExists, deleteADUser, searchADGroups, addUserToGroups, browseADGroups };

240
backend/ldapSync.js Normal file
View File

@@ -0,0 +1,240 @@
const { Client } = require('ldapts');
/**
* LDAP / Active Directory Sync Module (ldapts)
*
* Reads users from LDAP/AD and syncs them into the local SQLite database.
* AD users are identified by source='ad' and cannot be edited/deleted locally.
*
* Punkt 1: Migrated from ldapjs to ldapts
* Punkt 7: Proper client cleanup with try/finally
*
* ENV variables:
* LDAP_SERVER - e.g. pidc02.seatle.intra
* LDAP_PORT - e.g. 389 (LDAP) or 636 (LDAPS), default: 389
* LDAP_SEARCH_BASE - e.g. DC=SEATLE,DC=INTRA
* LDAP_DOMAIN - e.g. SEATLE (used for reference)
* LDAP_IGNORE_CERT_ERRORS- true/false (default: false)
* LDAP_BIND_USER - Service account in user@domain.fqdn format
* LDAP_BIND_PASSWORD - Password for the service account
* LDAP_SYNC_INTERVAL - Sync interval in ms (default: 300000 = 5 min)
* LDAP_FILTER - Custom LDAP filter (default: active users)
* LDAP_ATTRIBUTES - Comma-separated LDAP attributes
*/
const LDAP_SERVER = process.env.LDAP_SERVER || '';
const LDAP_PORT = parseInt(process.env.LDAP_PORT) || 389;
const LDAP_SEARCH_BASE = process.env.LDAP_SEARCH_BASE || '';
const LDAP_DOMAIN = process.env.LDAP_DOMAIN || '';
const LDAP_IGNORE_CERT_ERRORS = (process.env.LDAP_IGNORE_CERT_ERRORS || 'false').toLowerCase() === 'true';
const LDAP_BIND_USER = process.env.LDAP_BIND_USER || '';
const LDAP_BIND_PASSWORD = process.env.LDAP_BIND_PASSWORD || '';
const LDAP_SYNC_INTERVAL = parseInt(process.env.LDAP_SYNC_INTERVAL) || 300000;
const LDAP_FILTER = process.env.LDAP_FILTER || '(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))';
const LDAP_ATTRIBUTES = (process.env.LDAP_ATTRIBUTES || 'mail,displayName,memberOf,distinguishedName,sAMAccountName').split(',').map(a => a.trim());
let syncTimer = null;
let isSyncing = false; // Punkt 9: Sync lock to prevent concurrent syncs
function isLDAPConfigured() {
return !!(LDAP_SERVER && LDAP_SEARCH_BASE && LDAP_BIND_USER && LDAP_BIND_PASSWORD);
}
function extractRole(memberOf) {
if (!memberOf) return 'user';
const groups = Array.isArray(memberOf) ? memberOf : [memberOf];
const groupStrings = groups.map(g => String(g).toLowerCase());
if (groupStrings.some(g => g.includes('admin') || g.includes('domain admins') || g.includes('domänen-admins'))) {
return 'admin';
}
return 'user';
}
async function syncLDAPUsers(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] Nicht konfiguriert - LDAP-Sync deaktiviert.');
return;
}
// Punkt 9: Prevent concurrent sync runs
if (isSyncing) {
console.log('[LDAP] Sync bereits aktiv - übersprungen.');
return;
}
isSyncing = true;
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
console.log('[LDAP] Starte Synchronisation mit', url);
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(LDAP_BIND_USER, LDAP_BIND_PASSWORD);
console.log('[LDAP] Bind erfolgreich, suche Nutzer...');
const { searchEntries } = await client.search(LDAP_SEARCH_BASE, {
filter: LDAP_FILTER,
scope: 'sub',
attributes: LDAP_ATTRIBUTES,
});
const adUsers = [];
for (const entry of searchEntries) {
// ldapts may return attributes as arrays; normalize to single values
const rawMail = Array.isArray(entry.mail) ? entry.mail[0] : entry.mail;
const rawName = Array.isArray(entry.displayName) ? entry.displayName[0] : entry.displayName;
const rawCn = Array.isArray(entry.cn) ? entry.cn[0] : entry.cn;
const rawDN = Array.isArray(entry.distinguishedName) ? entry.distinguishedName[0] : entry.distinguishedName;
const rawSAM = Array.isArray(entry.sAMAccountName) ? entry.sAMAccountName[0] : entry.sAMAccountName;
const rawMemberOf = Array.isArray(entry.memberOf) ? entry.memberOf : (entry.memberOf ? [entry.memberOf] : []);
const email = (rawMail || '').toLowerCase().trim();
const name = rawName || rawCn || '';
const distinguishedName = rawDN || '';
const memberOf = rawMemberOf;
const username = (rawSAM || '').trim();
if (!email && !username) continue; // Skip users without email AND username
adUsers.push({
email: email || (username + '@ad.local'),
name,
role: extractRole(memberOf),
distinguishedName,
username,
});
}
console.log('[LDAP] Gefunden:', adUsers.length, 'Nutzer');
// Sync LDAP users into database (async for PostgreSQL compatibility)
const existingRows = await db.prepare('SELECT id, email, name, role, status FROM users WHERE source = \'ad\'').all();
const existingMap = {};
existingRows.forEach(row => { existingMap[row.email.toLowerCase()] = row; });
let inserted = 0;
let updated = 0;
const insertStmt = db.prepare('INSERT INTO users (email, password, name, role, status, source, username) VALUES (?, ?, ?, ?, \'inaktiv\', \'ad\', ?)');
const updateStmt = db.prepare('UPDATE users SET name = ?, username = ?, role = ? WHERE id = ?');
const syncTransaction = db.transaction(async () => {
for (const adUser of adUsers) {
const existing = existingMap[adUser.email];
if (existing) {
await updateStmt.run(adUser.name, adUser.username, adUser.role, existing.id);
updated++;
delete existingMap[adUser.email];
} else {
try {
await insertStmt.run(adUser.email, 'LDAP_AUTH', adUser.name, adUser.role, adUser.username);
inserted++;
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint') || err.message?.includes('duplicate key')) {
console.warn('[LDAP] E-Mail bereits vorhanden:', adUser.email);
} else {
console.error('[LDAP] Insert-Fehler:', err.message);
}
}
}
}
});
await syncTransaction();
// Remove stale AD users
const adEmails = adUsers.map(u => u.email.toLowerCase());
const toRemove = existingRows.filter(r => !adEmails.includes(r.email.toLowerCase()));
let removed = 0;
if (toRemove.length > 0) {
const removeIds = toRemove.map(r => r.id).filter(id => Number.isInteger(id));
if (removeIds.length > 0) {
const placeholders = removeIds.map(() => '?').join(',');
await db.prepare(`DELETE FROM users WHERE id IN (${placeholders}) AND source = 'ad'`).run(...removeIds);
removed = removeIds.length;
}
}
console.log('[LDAP] Sync abgeschlossen: ' + inserted + ' neu, ' + updated + ' aktualisiert, ' + removed + ' entfernt');
} catch (err) {
console.error('[LDAP] Sync-Fehler:', err.message);
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
isSyncing = false; // Punkt 9: Release sync lock
}
}
function startLDAPSync(db) {
if (!isLDAPConfigured()) {
console.log('[LDAP] LDAP-Sync nicht konfiguriert. Setze LDAP_SERVER, LDAP_SEARCH_BASE, LDAP_BIND_USER und LDAP_BIND_PASSWORD Umgebungsvariablen.');
return;
}
// Initial sync
syncLDAPUsers(db);
// Periodic sync
if (syncTimer) clearInterval(syncTimer);
syncTimer = setInterval(() => {
syncLDAPUsers(db);
}, LDAP_SYNC_INTERVAL);
console.log('[LDAP] Automatischer Sync alle ' + (LDAP_SYNC_INTERVAL / 1000) + ' Sekunden aktiviert.');
}
function stopLDAPSync() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log('[LDAP] Sync gestoppt.');
}
}
/**
* Authenticate a user against LDAP/Active Directory.
* Uses the sAMAccountName (username) to bind to the LDAP server.
* Punkt 7: Proper client cleanup with try/finally
*/
async function authenticateLDAP(username, password) {
if (!isLDAPConfigured()) {
throw new Error('LDAP nicht konfiguriert.');
}
// VULN-11: LDAP Injection prevention - validate username
const safeUsername = String(username || '').replace(/[*()\\\x00]/g, '').trim();
if (!safeUsername || !/^[a-zA-Z0-9._-]+$/.test(safeUsername)) {
throw new Error('Ungueltiger Anmeldename.');
}
const useTLS = LDAP_PORT === 636;
const url = useTLS ? `ldaps://${LDAP_SERVER}:${LDAP_PORT}` : `ldap://${LDAP_SERVER}:${LDAP_PORT}`;
// Build the bind DN: username@domain.fqdn (UPN format)
const bindDomain = LDAP_BIND_USER.split('@')[1] || LDAP_DOMAIN;
const bindDN = safeUsername + '@' + bindDomain;
const client = new Client({
url,
tlsOptions: useTLS && LDAP_IGNORE_CERT_ERRORS ? { rejectUnauthorized: false } : undefined,
connectTimeout: 10000,
});
try {
await client.bind(bindDN, password);
console.log('[LDAP] Authentifizierung erfolgreich für', bindDN);
return { username: username, bindDN: bindDN };
} catch (err) {
console.log('[LDAP] Authentifizierung fehlgeschlagen für', bindDN, ':', err.message);
throw new Error('Ungueltige Anmeldedaten.');
} finally {
await client.unbind().catch(() => {}); // Punkt 7: Always cleanup
}
}
module.exports = { isLDAPConfigured, syncLDAPUsers, startLDAPSync, stopLDAPSync, authenticateLDAP };

170
backend/middleware/auth.js Normal file
View File

@@ -0,0 +1,170 @@
/**
* Auth middleware module (async).
*
* Session tokens are hashed with SHA-256 for security (Punkt 4).
* All DB calls are async (Punkt 4: PostgreSQL compatibility).
*/
const crypto = require('crypto');
const db = require('../db');
const { auditLog } = require('../auditLog');
// P6: Cookie config - defined early for use in CSRF and auth cookies
const isProduction = process.env.NODE_ENV === 'production';
const COOKIE_NAME = 'workflow_token';
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
// P4: CSRF protection (Double-Submit-Cookie pattern)
const CSRF_COOKIE_NAME = 'workflow_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
function setCSRFCookie(res) {
const csrfToken = crypto.randomBytes(32).toString('hex');
res.cookie(CSRF_COOKIE_NAME, csrfToken, {
httpOnly: false, // Must be readable by JS to send back in header
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24h
path: '/',
});
return csrfToken;
}
function csrfMiddleware(req, res, next) {
// Only check state-changing methods
const stateChanging = ['POST', 'PUT', 'PATCH', 'DELETE'];
if (!stateChanging.includes(req.method)) return next();
const cookieToken = req.cookies?.[CSRF_COOKIE_NAME];
const headerToken = req.headers[CSRF_HEADER_NAME];
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF-Token ungültig oder fehlend.' });
}
next();
}
async function authMiddleware(req, res, next) {
// Punkt 8: Token from HttpOnly-Cookie OR Authorization header
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
if (!rawToken) return res.status(401).json({ error: 'Nicht authentifiziert.' });
const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT s.id, s.user_id, s.expires_at, u.email, u.name, u.role, u.status, u.source, u.username FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.token = ?').get(tokenHash);
if (!session) return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
if (session.status === 'inaktiv') {
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
return res.status(401).json({ error: 'Konto deaktiviert.' });
}
if (session.expires_at && new Date(session.expires_at) < new Date()) {
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte erneut anmelden.' });
}
req.user = { id: session.user_id, email: session.email, name: session.name, role: session.role, status: session.status, source: session.source, username: session.username };
req.tokenHash = tokenHash;
next();
}
function adminMiddleware(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin-Rechte erforderlich.' });
next();
}
async function createSession(userId, oldRawToken) {
// V6: Session-Rotation - invalidate old session on new login (prevents session fixation)
if (oldRawToken) {
const oldHash = hashToken(oldRawToken);
await db.prepare('DELETE FROM sessions WHERE token = ?').run(oldHash);
}
const rawToken = crypto.randomBytes(32).toString('hex');
const tokenHash = hashToken(rawToken);
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString();
// Punkt 9: Session-Limitierung - max sessions per user
const maxSessions = parseInt(process.env.SESSION_MAX_PER_USER) || 5;
const existingSessions = await db.prepare('SELECT id FROM sessions WHERE user_id = ? ORDER BY created_at ASC').all(userId);
if (existingSessions.length >= maxSessions) {
const toDelete = existingSessions.slice(0, existingSessions.length - maxSessions + 1);
const deleteIds = toDelete.map(s => s.id).filter(id => Number.isInteger(id));
if (deleteIds.length > 0) {
const placeholders = deleteIds.map(() => '?').join(',');
await db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...deleteIds);
}
}
await db.prepare('INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, ?)').run(userId, tokenHash, expiresAt);
return rawToken;
}
async function deleteSession(rawToken) {
if (!rawToken) return;
const tokenHash = hashToken(rawToken);
const session = await db.prepare('SELECT user_id FROM sessions WHERE token = ?').get(tokenHash);
if (session) {
auditLog(session.user_id, 'logout', 'user', session.user_id, null);
}
await db.prepare('DELETE FROM sessions WHERE token = ?').run(tokenHash);
}
async function invalidateUserSessions(userId) {
await db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
}
// Punkt 12: Account-Lockout functions
const MAX_ATTEMPTS = parseInt(process.env.LOGIN_MAX_ATTEMPTS) || 5;
const LOCKOUT_MINUTES = parseInt(process.env.LOGIN_LOCKOUT_MINUTES) || 15;
async function isAccountLocked(userId) {
const user = await db.prepare('SELECT locked_until FROM users WHERE id = ?').get(userId);
if (!user || !user.locked_until) return false;
if (new Date(user.locked_until) > new Date()) return true;
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
return false;
}
async function recordFailedLogin(userId) {
if (!userId) return;
const user = await db.prepare('SELECT failed_login_attempts FROM users WHERE id = ?').get(userId);
if (!user) return;
const attempts = (user.failed_login_attempts || 0) + 1;
if (attempts >= MAX_ATTEMPTS) {
const lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
await db.prepare('UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE id = ?').run(attempts, lockedUntil, userId);
} else {
await db.prepare('UPDATE users SET failed_login_attempts = ? WHERE id = ?').run(attempts, userId);
}
}
async function recordSuccessfulLogin(userId) {
if (!userId) return;
await db.prepare('UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = ?').run(userId);
}
// Punkt 8: Cookie helpers
function setAuthCookie(res, token) {
const ttlHours = parseInt(process.env.SESSION_TTL_HOURS) || 168;
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
maxAge: ttlHours * 60 * 60 * 1000,
path: '/',
});
}
function clearAuthCookie(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
module.exports = {
authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, hashToken,
isAccountLocked, recordFailedLogin, recordSuccessfulLogin,
setAuthCookie, clearAuthCookie, COOKIE_NAME,
setCSRFCookie, csrfMiddleware, CSRF_COOKIE_NAME, CSRF_HEADER_NAME
};

View File

@@ -0,0 +1,46 @@
/**
* Rate limiting configuration module.
*
* Punkt 23: User-level rate limiting for critical endpoints.
*/
const rateLimit = require('express-rate-limit');
// General API rate limit: 100 requests per minute per IP
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Anfragen. Bitte später erneut versuchen.' },
});
// Login rate limit: 5 attempts per minute per IP (brute-force protection)
const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Anmeldeversuche. Bitte in 1 Minute erneut versuchen.' },
});
// Punkt 23: Task creation rate limit: 20 per minute per user
const taskCreateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => !req.user,
message: { error: 'Zu viele Auftragsanfragen. Bitte später erneut versuchen.' },
});
// Punkt 23: File upload rate limit: 10 per minute per user
const uploadLimiter = rateLimit({
windowMs: 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => !req.user,
message: { error: 'Zu viele Upload-Anfragen. Bitte später erneut versuchen.' },
});
module.exports = { apiLimiter, loginLimiter, taskCreateLimiter, uploadLimiter };

View File

@@ -0,0 +1,209 @@
/**
* Input Validation Module (zod)
*
* Punkt 2: Schema-based input validation for all API routes.
* Provides reusable validation schemas and a middleware helper.
*/
const { z } = require('zod');
// ============ Auth Schemas ============
// Punkt 11: Password-Policy - min 8 chars, uppercase, lowercase, number
const passwordSchema = z.string()
.min(8, 'Passwort muss mindestens 8 Zeichen lang sein.')
.regex(/[A-Z]/, 'Passwort muss mindestens einen Grossbuchstaben enthalten.')
.regex(/[a-z]/, 'Passwort muss mindestens einen Kleinbuchstaben enthalten.')
.regex(/[0-9]/, 'Passwort muss mindestens eine Zahl enthalten.');
const registerSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.'),
password: passwordSchema,
name: z.string().max(100).optional().default(''),
// VULN-FIX: role removed - always 'user' on register, never trust client
});
const loginSchema = z.object({
email: z.string().min(1, 'E-Mail ist erforderlich.'),
password: z.string().min(1, 'Passwort ist erforderlich.'),
});
// ============ User Schemas ============
const createUserSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.'),
password: passwordSchema,
name: z.string().max(100).optional().default(''),
role: z.enum(['admin', 'user']).optional().default('user'),
status: z.enum(['aktiv', 'inaktiv']).optional().default('aktiv'),
});
const updateUserSchema = z.object({
email: z.string().email('Ungueltige E-Mail-Adresse.').optional(),
name: z.string().max(100).optional(),
password: passwordSchema.optional(),
current_password: z.string().optional(),
role: z.enum(['admin', 'user']).optional(),
status: z.enum(['aktiv', 'inaktiv']).optional(),
});
// ============ Template Schemas ============
const templateStepSchema = z.object({
page_num: z.number().int().min(1).optional().default(1),
label: z.string().min(1, 'Label ist erforderlich.').max(200),
type: z.enum(['checkbox', 'text_input', 'file_upload', 'email', 'dropdown', 'ad_password', 'ad_displayname']),
step_order: z.number().int().min(0).optional(),
email_domain: z.string().optional(),
email_source_fields: z.string().optional(),
dropdown_options: z.string().optional(),
ad_field: z.string().optional(),
ad_prefix: z.string().optional(),
hidden: z.boolean().optional().default(false),
});
const createTemplateSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich.').max(200),
description: z.string().max(1000).optional().default(''),
is_assignable: z.boolean().optional().default(false),
allows_file_upload: z.boolean().optional().default(false),
ad_create: z.boolean().optional().default(false),
steps: z.array(templateStepSchema).optional().default([]),
});
const updateTemplateSchema = z.object({
name: z.string().min(1, 'Name ist erforderlich.').max(200),
description: z.string().max(1000).optional().default(''),
is_assignable: z.boolean().optional().default(false),
allows_file_upload: z.boolean().optional().default(false),
ad_create: z.boolean().optional().default(false),
steps: z.array(templateStepSchema).optional().default([]),
});
// ============ Task Schemas ============
const createTaskSchema = z.object({
template_id: z.number().int().positive('Template-ID ist erforderlich.'),
title: z.string().min(1, 'Titel ist erforderlich.').max(500),
user_id: z.number().int().positive().optional(),
file_path: z.string().optional(),
// V9: Limit task values array to prevent DoS via huge payloads
values: z.array(z.object({
step_id: z.number().int().positive().optional(),
value: z.string().max(10000).optional(),
is_checked: z.boolean().optional(),
file_path: z.string().optional(),
})).max(100, 'Maximal 100 Werte pro Aufgabe erlaubt.').optional().default([]),
});
const updateTaskStatusSchema = z.object({
status: z.enum(['offen', 'erledigt'], { message: 'Status muss "offen" oder "erledigt" sein.' }),
});
const updateTaskValuesSchema = z.object({
values: z.array(z.object({
id: z.number().int().positive(),
value: z.string().optional(),
is_checked: z.boolean().optional(),
})).min(1, 'Mindestens ein Wert ist erforderlich.'),
});
const addTaskFieldSchema = z.object({
label: z.string().min(1, 'Label ist erforderlich.').max(200),
type: z.enum(['text_input', 'checkbox', 'dropdown', 'email']).optional().default('text_input'),
value: z.string().optional().default(''),
page_num: z.number().int().min(1).optional().default(1),
dropdown_options: z.string().optional().default(''),
ad_field: z.string().optional().default(''),
hidden: z.boolean().optional().default(false),
email_source_fields: z.string().optional().default(''),
});
// ============ AD Schemas ============
const createADUserSchema = z.object({
ou: z.string().min(1, 'OU ist erforderlich.'),
vorname: z.string().min(1, 'Vorname ist erforderlich.').max(100),
nachname: z.string().min(1, 'Nachname ist erforderlich.').max(100),
username: z.string().min(1, 'Anmeldename ist erforderlich.').max(50),
password: z.string().min(1, 'Passwort ist erforderlich.').min(8, 'Passwort muss mindestens 8 Zeichen lang sein.'),
email: z.string().email().optional(),
department: z.string().max(100).optional(),
telefon: z.string().max(50).optional(),
titel: z.string().max(100).optional(),
displayName: z.string().max(200).optional(),
physicalDeliveryOfficeName: z.string().max(100).optional(),
company: z.string().max(100).optional(),
description: z.string().max(500).optional(),
wWWHomePage: z.string().max(200).optional(),
streetAddress: z.string().max(200).optional(),
postOfficeBox: z.string().max(50).optional(),
l: z.string().max(100).optional(),
st: z.string().max(100).optional(),
postalCode: z.string().max(20).optional(),
c: z.string().max(2).optional(),
groups: z.array(z.string()).optional(),
});
const deleteADUserSchema = z.object({
dn: z.string().min(1, 'DN ist erforderlich.'),
});
// ============ Search/Query Schemas ============
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(100).optional().default(20),
});
const searchSchema = z.object({
search: z.string().max(100).optional(),
});
// ============ Validation Middleware ============
function validate(schema) {
return (req, res, next) => {
try {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map(e => e.message).join(', ');
return res.status(400).json({ error: errors });
}
req.validatedBody = result.data;
next();
} catch (err) {
return res.status(400).json({ error: 'Ungueltige Eingabe.' });
}
};
}
function validateQuery(schema) {
return (req, res, next) => {
try {
const result = schema.safeParse(req.query);
if (!result.success) {
const errors = result.error.errors.map(e => e.message).join(', ');
return res.status(400).json({ error: errors });
}
req.validatedQuery = result.data;
next();
} catch (err) {
return res.status(400).json({ error: 'Ungueltige Abfrage.' });
}
};
}
module.exports = {
// Schemas
registerSchema,
loginSchema,
createUserSchema,
updateUserSchema,
createTemplateSchema,
updateTemplateSchema,
templateStepSchema,
createTaskSchema,
updateTaskStatusSchema,
updateTaskValuesSchema,
addTaskFieldSchema,
createADUserSchema,
deleteADUserSchema,
paginationSchema,
searchSchema,
// Middleware
validate,
validateQuery,
};

202
backend/migrations.js Normal file
View File

@@ -0,0 +1,202 @@
/**
* Database initialization and migrations module.
*
* Punkt 4: Supports both SQLite and PostgreSQL.
* Migrations are tracked in a _migrations table to avoid re-running.
*
* Note: SQLite mode is synchronous, PostgreSQL mode is async.
* The initDatabase function handles both cases.
*/
const db = require('./db');
const isPostgres = db._type === 'postgres';
// Helper: convert SQLite SQL to PostgreSQL-compatible SQL
function toPg(sql) {
return sql
.replace(/INTEGER PRIMARY KEY AUTOINCREMENT/g, 'SERIAL PRIMARY KEY')
.replace(/TIMESTAMP DEFAULT CURRENT_TIMESTAMP/g, 'TIMESTAMP DEFAULT NOW()')
.replace(/`/g, '"');
}
function execSql(sql) {
if (isPostgres) {
return db.exec(toPg(sql));
}
return db.exec(sql);
}
async function initDatabase() {
// Create migrations tracking table
const migrationsTableSql = isPostgres
? `CREATE TABLE IF NOT EXISTS _migrations (id SERIAL PRIMARY KEY, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT NOW())`
: `CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)`;
await execSql(migrationsTableSql);
const appliedRows = await db.prepare('SELECT name FROM _migrations').all();
const applied = new Set(appliedRows.map(r => r.name));
async function migrate(name, sql) {
if (applied.has(name)) return;
console.log(`[Migration] ${name}...`);
await execSql(sql);
await db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(name);
console.log(`[Migration] ${name} done.`);
}
// Base schema
const baseSchema = isPostgres ? `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin', 'user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv', 'inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local', 'ad')) DEFAULT 'local',
username TEXT,
failed_login_attempts INTEGER DEFAULT 0,
locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id SERIAL PRIMARY KEY, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW(), expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
` : `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, name TEXT NOT NULL DEFAULT '',
role TEXT CHECK(role IN ('admin','user')) DEFAULT 'user',
status TEXT CHECK(status IN ('aktiv','inaktiv')) DEFAULT 'inaktiv',
source TEXT CHECK(source IN ('local','ad')) DEFAULT 'local',
username TEXT, failed_login_attempts INTEGER DEFAULT 0, locked_until TIMESTAMP DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS templates (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT,
is_assignable INTEGER DEFAULT 0, allows_file_upload INTEGER DEFAULT 0, ad_create INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS template_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, page_num INTEGER NOT NULL,
label TEXT NOT NULL, type TEXT CHECK(type IN ('checkbox','text_input','file_upload','email','dropdown','ad_password','ad_displayname')) NOT NULL,
step_order INTEGER NOT NULL, email_domain TEXT, email_source_fields TEXT, dropdown_options TEXT,
ad_field TEXT, ad_prefix TEXT, hidden INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
title TEXT NOT NULL, status TEXT CHECK(status IN ('offen','erledigt')) DEFAULT 'offen',
file_path TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_values (
id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, step_id INTEGER, value TEXT, is_checked INTEGER DEFAULT 0,
file_path TEXT, custom_label TEXT, custom_type TEXT DEFAULT 'text_input',
custom_dropdown_options TEXT, custom_ad_field TEXT, custom_hidden INTEGER DEFAULT 0, custom_email_source_fields TEXT,
snap_label TEXT, snap_type TEXT, snap_page_num INTEGER, snap_ad_field TEXT, snap_ad_prefix TEXT,
snap_dropdown_options TEXT, snap_email_source_fields TEXT, snap_hidden INTEGER DEFAULT 0,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (step_id) REFERENCES template_steps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, token TEXT UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, action TEXT NOT NULL, entity_type TEXT, entity_id INTEGER,
details TEXT, ip_address TEXT, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
`;
await execSql(baseSchema);
// Indexes
const indexes = [
'idx_sessions_token', 'idx_sessions_user_id', 'idx_sessions_expires',
'idx_tasks_user_id', 'idx_tasks_template_id', 'idx_tasks_status',
'idx_task_values_task_id', 'idx_task_values_step_id',
'idx_audit_log_created', 'idx_audit_log_user', 'idx_users_email', 'idx_users_source',
];
for (const idx of indexes) {
const table = idx.replace('idx_', '').replace('_id', '').replace('_at', '_created').replace('_token', '_token');
// Build CREATE INDEX statement
let col;
if (idx === 'idx_sessions_token') col = 'sessions(token)';
else if (idx === 'idx_sessions_user_id') col = 'sessions(user_id)';
else if (idx === 'idx_sessions_expires') col = 'sessions(expires_at)';
else if (idx === 'idx_tasks_user_id') col = 'tasks(user_id)';
else if (idx === 'idx_tasks_template_id') col = 'tasks(template_id)';
else if (idx === 'idx_tasks_status') col = 'tasks(status)';
else if (idx === 'idx_task_values_task_id') col = 'task_values(task_id)';
else if (idx === 'idx_task_values_step_id') col = 'task_values(step_id)';
else if (idx === 'idx_audit_log_created') col = 'audit_log(created_at)';
else if (idx === 'idx_audit_log_user') col = 'audit_log(user_id)';
else if (idx === 'idx_users_email') col = 'users(email)';
else if (idx === 'idx_users_source') col = 'users(source)';
await migrate(idx, `CREATE INDEX IF NOT EXISTS ${idx} ON ${col}`);
}
// Seed admin user
const bcrypt = require('bcryptjs');
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@workflow.local';
const ADMIN_INIT_PASSWORD = process.env.ADMIN_INIT_PASSWORD || '';
const existingAdmin = await db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL);
if (!existingAdmin) {
if (!ADMIN_INIT_PASSWORD) {
console.warn('WARNUNG: Kein ADMIN_INIT_PASSWORD gesetzt - kein Admin-Account erstellt.');
} else {
const hash = bcrypt.hashSync(ADMIN_INIT_PASSWORD, 12);
await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'admin\', \'aktiv\', \'local\')').run(ADMIN_EMAIL, hash, 'Superadmin');
console.log('Superadmin erstellt: ' + ADMIN_EMAIL);
}
}
// Periodic session cleanup
setInterval(async () => {
try {
const cleanupSql = isPostgres ? "DELETE FROM sessions WHERE expires_at < NOW()" : "DELETE FROM sessions WHERE expires_at < datetime('now')";
await db.prepare(cleanupSql).run();
} catch (err) {
console.error('Session cleanup error:', err.message);
}
}, 60 * 60 * 1000);
console.log('Datenbanktabellen initialisiert.');
}
module.exports = { initDatabase };

1974
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
backend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "workflow-backend",
"version": "1.0.0",
"description": "Workflow Portal Backend",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.7.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^8.6.2",
"helmet": "^8.2.0",
"ldapts": "^8.2.0",
"multer": "^2.2.0",
"pg": "^8.13.0",
"zod": "^3.24.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

124
backend/routes/ad.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* AD/LDAP routes module.
*/
const express = require('express');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { auditLog } = require('../auditLog');
const { isLDAPConfigured } = require('../ldapSync');
const { browseOUTree, createADUser, checkADUserExists, deleteADUser } = require('../ldapOperations');
const { searchADGroups, addUserToGroups, browseADGroups } = require('../ldapOperations');
const { validate, createADUserSchema, deleteADUserSchema } = require('../middleware/validation');
const router = express.Router();
// LDAP Status Endpoint (public)
router.get('/status', (req, res) => {
res.json({ configured: isLDAPConfigured() });
});
// All other AD routes require auth
router.use(authMiddleware);
// Browse OU tree
router.get('/ou-tree', (req, res) => {
const { base } = req.query;
browseOUTree(base || undefined).then(ous => {
res.json(ous);
}).catch(err => {
console.error('[ERROR] GET /ad/ou-tree -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Search AD groups (all security groups)
router.get('/groups', (req, res) => {
const { q } = req.query;
if (!q || q.trim().length < 2) {
return res.json([]);
}
searchADGroups(q.trim()).then(groups => {
res.json(groups);
}).catch(err => {
console.error('[ERROR] GET /ad/groups -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Browse all AD groups (for tree display)
router.get('/groups-tree', (req, res) => {
browseADGroups().then(groups => {
res.json(groups);
}).catch(err => {
console.error('[ERROR] GET /ad/groups-tree -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
});
// Get AD create config
router.get('/create-config', (req, res) => {
res.json({
configured: isLDAPConfigured(),
createOU: process.env.LDAP_CREATE_OU || '',
upnSuffix: process.env.LDAP_UPN_SUFFIX || process.env.LDAP_BIND_USER?.split('@')[1] || '',
});
});
// Create AD user (admin only)
router.post('/create-user', adminMiddleware, validate(createADUserSchema), async (req, res) => {
const { ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c, groups } = req.validatedBody;
try {
let sAMAccountName = username;
if (vorname && nachname) {
sAMAccountName = nachname.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss')
+ vorname.charAt(0).replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue').replace(/ß/g, 'ss');
sAMAccountName = sAMAccountName.replace(/[^a-zA-Z0-9]/g, '').substring(0, 20);
}
const existing = await checkADUserExists(username, sAMAccountName);
if (existing) {
return res.status(409).json({ error: 'Benutzername "' + username + '" existiert bereits im Active Directory.', dn: existing.distinguishedName });
}
const result = await createADUser({ ou, vorname, nachname, email, username, password, department, telefon, titel, displayName, physicalDeliveryOfficeName, company, description, wWWHomePage, streetAddress, postOfficeBox, l, st, postalCode, c });
if (result.warning && result.dn) {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `AD user created with warning: ${username} - ${result.warning}`);
} else {
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Created AD user: ${username}`);
}
// Add user to groups if specified
let groupResults = [];
if (groups && Array.isArray(groups) && groups.length > 0 && result.dn) {
try {
groupResults = await addUserToGroups(result.dn, groups);
const addedCount = groupResults.filter(r => r.status === 'added').length;
auditLog(req.user?.id, 'ad.create-user', 'ad_user', null, `Added ${username} to ${addedCount} group(s)`);
} catch (groupErr) {
console.error('[WARN] Gruppenzuweisung fehlgeschlagen:', groupErr.message);
groupResults = groups.map(dn => ({ dn, status: 'error', error: groupErr.message }));
}
}
res.status(201).json({ ...result, groupResults });
} catch (err) {
auditLog(req.user?.id, 'ad.create-user-failed', 'ad_user', null, `Failed to create AD user: ${username} - ${err.message}`);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete AD user (admin only)
router.delete('/delete-user', adminMiddleware, validate(deleteADUserSchema), async (req, res) => {
const { dn } = req.validatedBody;
try {
await deleteADUser(dn);
auditLog(req.user?.id, 'ad.delete-user', 'ad_user', null, `Deleted AD user: ${dn}`);
res.json({ success: true, message: 'Benutzer erfolgreich gelöscht.' });
} catch (err) {
res.status(500).json({ error: 'Fehler beim Löschen des AD-Benutzers: ' + err.message });
}
});
module.exports = router;

147
backend/routes/auth.js Normal file
View File

@@ -0,0 +1,147 @@
/**
* Auth routes module.
*
* Punkt 5: Register returns correct status ('inaktiv').
* Punkt 4: Session tokens are hashed (SHA-256) before storage.
* Punkt 6: Uses better-sqlite3 synchronous API.
*/
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, createSession, deleteSession, invalidateUserSessions, isAccountLocked, recordFailedLogin, recordSuccessfulLogin, setAuthCookie, clearAuthCookie, setCSRFCookie } = require('../middleware/auth');
const { isLDAPConfigured, authenticateLDAP } = require('../ldapSync');
const { loginLimiter } = require('../middleware/rateLimit');
const { validate, registerSchema, loginSchema } = require('../middleware/validation');
const router = express.Router();
// Register
router.post('/register', validate(registerSchema), async (req, res) => {
const { email, password, name } = req.validatedBody;
try {
const hash = bcrypt.hashSync(password, 10);
// VULN-FIX: Force role to 'user' - never trust client-supplied role on register
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, \'user\', \'inaktiv\', \'local\')').run(email, hash, name);
const userId = info.lastInsertRowid;
auditLog(null, 'register', 'user', userId, `New registration: ${email}`);
// P4: Set CSRF cookie for the new session
const csrfToken = setCSRFCookie(res);
// Return correct status 'inaktiv' (Punkt 5 fix)
res.status(201).json({ id: userId, email, name, role: 'user', status: 'inaktiv', source: 'local', csrfToken, message: 'Registrierung erfolgreich. Ein Administrator muss dein Konto freischalten.' });
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Login
router.post('/login', loginLimiter, validate(loginSchema), async (req, res) => {
const { email, password } = req.validatedBody;
const row = await db.prepare('SELECT id, email, name, role, status, source, username, password FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, email);
// Punkt 12: Account-Lockout check
if (row && await isAccountLocked(row.id)) {
return res.status(423).json({ error: 'Konto gesperrt wegen zu vieler fehlgeschlagener Anmeldeversuche. Bitte später erneut versuchen.' });
}
// If user not found locally, try LDAP auth
if (!row) {
if (isLDAPConfigured()) {
try {
const ldapResult = await authenticateLDAP(email, password);
const adRow = await db.prepare('SELECT id, email, name, role, status, source, username FROM users WHERE LOWER(username) = LOWER(?)').get(ldapResult.username);
if (!adRow) return res.status(404).json({ error: 'Nutzer im System nicht gefunden. Bitte warte auf die naechste Synchronisation.' });
if (adRow.status === 'inaktiv') return res.status(403).json({ error: 'Dein Konto ist deaktiviert.' });
await recordSuccessfulLogin(adRow.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(adRow.id, oldToken);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(adRow.id, 'login', 'user', adRow.id, 'AD login');
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...adRow, csrfToken });
} catch (ldapErr) {
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
} else {
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
return;
}
if (row.status === 'inaktiv') {
return res.status(403).json({ error: 'Dein Konto ist deaktiviert. Bitte wende dich an einen Administrator.' });
}
if (row.source === 'ad') {
if (!isLDAPConfigured()) {
return res.status(403).json({ error: 'AD-Anmeldung nicht konfiguriert.' });
}
try {
await authenticateLDAP(row.username || row.email.split('@')[0], password);
await recordSuccessfulLogin(row.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldTokenAD = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenAD);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(row.id, 'login', 'user', row.id, 'AD login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
} catch (ldapErr) {
await recordFailedLogin(row.id);
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
} else {
// Local user - check password with bcrypt (auto-upgrade from plaintext)
let passwordMatch = false;
if (row.password.startsWith('$2a$') || row.password.startsWith('$2b$')) {
passwordMatch = bcrypt.compareSync(password, row.password);
} else {
// Legacy plaintext comparison - auto-upgrade to bcrypt
passwordMatch = row.password === password;
if (passwordMatch) {
// P8: Log plaintext login for security monitoring (auto-upgrade follows)
auditLog(row.id, 'plaintext_login_upgraded', 'user', row.id, 'Legacy plaintext password upgraded to bcrypt');
console.warn('[SECURITY] User', row.email, 'logged in with plaintext password - upgrading to bcrypt.');
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
await db.prepare('UPDATE users SET password = ? WHERE id = ?').run(hash, row.id);
}
}
if (!passwordMatch) {
await recordFailedLogin(row.id);
return res.status(401).json({ error: 'Ungueltige Anmeldedaten.' });
}
await recordSuccessfulLogin(row.id);
// V6: Pass old token for session rotation (prevents session fixation)
const oldTokenLocal = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
const rawToken = await createSession(row.id, oldTokenLocal);
setAuthCookie(res, rawToken); // Punkt 8: HttpOnly-Cookie
const csrfToken = setCSRFCookie(res); // P4: CSRF cookie
auditLog(row.id, 'login', 'user', row.id, 'Local login');
const { password: _, ...safeRow } = row;
// Bug 6: Don't expose token in response body (cookie-only auth)
res.json({ ...safeRow, csrfToken });
}
});
// Logout
router.post('/logout', async (req, res) => {
const rawToken = req.cookies?.workflow_token || req.headers.authorization?.replace('Bearer ', '');
await deleteSession(rawToken);
clearAuthCookie(res); // Punkt 8: Clear HttpOnly-Cookie
res.json({ message: 'Abgemeldet.' });
});
// Check session
router.get('/me', authMiddleware, (req, res) => {
res.json(req.user);
});
module.exports = router;

83
backend/routes/stats.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* Stats and audit-log routes module.
*
* Punkt 7: Single aggregated query for stats instead of 9 nested callbacks.
*/
const express = require('express');
const db = require('../db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { validateQuery, paginationSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
router.use(adminMiddleware);
// Punkt 7: Single aggregated stats query
router.get('/stats', async (req, res) => {
try {
const rawStats = await db.prepare(`
SELECT
(SELECT COUNT(*) FROM users WHERE status = 'aktiv') as activeUsers,
(SELECT COUNT(*) FROM users) as totalUsers,
(SELECT COUNT(*) FROM tasks WHERE status = 'offen') as openTasks,
(SELECT COUNT(*) FROM tasks WHERE status = 'erledigt') as completedTasks,
(SELECT COUNT(*) FROM tasks) as totalTasks,
(SELECT COUNT(*) FROM templates) as totalTemplates,
(SELECT COUNT(*) FROM templates WHERE is_assignable = 1) as assignableTemplates,
(SELECT COUNT(*) FROM users WHERE source = 'ad') as adUsers,
(SELECT COUNT(*) FROM users WHERE source = 'local') as localUsers
`).get();
// PostgreSQL lowercases aliases; normalize keys and coerce counts to numbers.
const normalizeKey = (key) => key.toLowerCase();
const keyMap = {
activeusers: 'activeUsers',
totalusers: 'totalUsers',
opentasks: 'openTasks',
completedtasks: 'completedTasks',
totaltasks: 'totalTasks',
totaltemplates: 'totalTemplates',
assignabletemplates: 'assignableTemplates',
adusers: 'adUsers',
localusers: 'localUsers'
};
const stats = {};
for (const [key, value] of Object.entries(rawStats)) {
const normalized = normalizeKey(key);
const newKey = keyMap[normalized] || normalized;
stats[newKey] = typeof value === 'string' ? Number(value) : value;
}
const topTemplates = await db.prepare(
'SELECT t.id, t.name, COUNT(tk.id) as task_count FROM templates t LEFT JOIN tasks tk ON t.id = tk.template_id GROUP BY t.id ORDER BY task_count DESC LIMIT 5'
).all();
const recentActivity = await db.prepare(
'SELECT al.*, u.name as user_name, u.email as user_email FROM audit_log al LEFT JOIN users u ON al.user_id = u.id ORDER BY al.created_at DESC LIMIT 10'
).all();
res.json({ ...stats, topTemplates, recentActivity });
} catch (err) {
console.error('[ERROR] GET /stats -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Audit log with pagination (Punkt 16: bounded limits)
router.get('/audit-log', validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
try {
const rows = await db.prepare('SELECT al.*, u.name as user_name, u.email as user_email FROM audit_log al LEFT JOIN users u ON al.user_id = u.id ORDER BY al.created_at DESC LIMIT ? OFFSET ?').all(limit, offset);
const countRow = await db.prepare('SELECT COUNT(*) as total FROM audit_log').get();
const total = countRow?.total || 0;
res.json({ entries: rows, total, page, limit, totalPages: Math.ceil(total / limit) });
} catch (err) {
console.error('[ERROR] GET /audit-log -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
module.exports = router;

216
backend/routes/tasks.js Normal file
View File

@@ -0,0 +1,216 @@
/**
* Tasks routes module.
*
* Punkt 8: Uses transactions for task creation with values.
* Punkt 6: Uses better-sqlite3 synchronous API.
* Punkt 23: Rate limiting on task creation.
*/
const express = require('express');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { taskCreateLimiter } = require('../middleware/rateLimit');
const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
// Create task - Punkt 8: Transaction
router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => {
const { template_id, title, values, file_path, user_id } = req.validatedBody;
// VULN-02: Mass Assignment prevention
const targetUserId = (req.user.role === 'admin' && req.body.user_id)
? parseInt(req.body.user_id)
: req.user.id;
if (!template_id || !title) {
return res.status(400).json({ error: 'template_id und title sind erforderlich.' });
}
const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)');
const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
const createTask = db.transaction(async () => {
const info = await insertTask.run(template_id, targetUserId, title, file_path);
const taskId = info.lastInsertRowid;
if (values.length > 0) {
// Fetch step metadata for snapshot
const stepIds = values.map(v => v.step_id).filter(Boolean);
const stepMetaMap = {};
if (stepIds.length > 0) {
const validStepIds = stepIds.filter(id => Number.isInteger(id));
if (validStepIds.length > 0) {
const placeholders = validStepIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds);
steps.forEach(s => { stepMetaMap[s.id] = s; });
}
}
for (const v of values) {
const meta = v.step_id ? stepMetaMap[v.step_id] : null;
await insertValue.run(
taskId, v.step_id, v.value || '', v.is_checked ? 1 : 0, v.file_path || null,
meta ? meta.label : null,
meta ? meta.type : null,
meta ? meta.page_num : null,
meta ? meta.ad_field : null,
meta ? meta.ad_prefix : null,
meta ? meta.dropdown_options : null,
meta ? meta.email_source_fields : null,
meta ? (meta.hidden ? 1 : 0) : 0
);
}
}
return taskId;
});
try {
const taskId = await createTask();
auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`);
res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values });
} catch (err) {
console.error('[ERROR] POST /tasks -', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update task status
router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { status } = req.validatedBody;
// VULN-05: BOLA protection
const task = await db.prepare('SELECT user_id FROM tasks WHERE id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
if (task.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe zu aendern.' });
}
const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`);
res.json({ id: taskId, status });
});
// Update task values (admin only)
router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { values } = req.validatedBody;
const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?');
const updateTransaction = db.transaction(async (vals) => {
let updated = 0;
for (const v of vals) {
const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId);
updated += info.changes;
}
return updated;
});
try {
const updated = await updateTransaction(values);
auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`);
res.json({ updated, taskId });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Add custom field to task (admin only)
router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), async (req, res) => {
const taskId = parseInt(req.params.id);
const { label, type, value, page_num, dropdown_options, ad_field, hidden, email_source_fields } = req.validatedBody;
const fieldType = type || 'text_input';
const fieldValue = value || '';
const customDropdownOptions = dropdown_options || '';
const customAdField = ad_field || '';
const customHidden = hidden ? 1 : 0;
const customEmailSourceFields = email_source_fields || '';
try {
const info = await db.prepare(
'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields);
auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`);
res.status(201).json({
id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType,
value: fieldValue, page_num: page_num || 1,
dropdown_options: customDropdownOptions, ad_field: customAdField,
hidden: customHidden, email_source_fields: customEmailSourceFields
});
} catch (err) {
console.error('Add field error:', err.message);
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete custom field from task (admin only)
router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' });
auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`);
res.json({ message: 'Feld gelöscht.' });
});
// Delete task (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const taskId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
auditLog(req.user?.id, 'delete_task', 'task', taskId, null);
res.json({ message: 'Aufgabe gelöscht.' });
});
// Single task endpoint
router.get('/:id', async (req, res) => {
const taskId = parseInt(req.params.id);
const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId);
if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' });
const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC`
).all(taskId);
auditLog(req.user?.id, 'view_task', 'task', taskId, null);
res.json({ ...task, values: values || [] });
});
// List tasks with pagination (Punkt 16: bounded limits)
router.get('/', validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
const status = req.query.status;
let whereClause = '';
const params = [];
if (status && ['offen', 'erledigt'].includes(status)) {
whereClause = ' WHERE t.status = ?';
params.push(status);
}
const countSql = 'SELECT COUNT(*) as total FROM tasks t' + whereClause;
const dataSql = 'SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id' + whereClause + ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?';
const countRow = await db.prepare(countSql).get(...params);
const tasks = await db.prepare(dataSql).all(...params, limit, offset);
const total = countRow?.total || 0;
if (tasks.length === 0) return res.json({ tasks: [], total: 0, page, limit, totalPages: 0 });
const taskIds = tasks.map(t => t.id).filter(id => Number.isInteger(id));
if (taskIds.length === 0) return res.json({ tasks: tasks.map(t => ({ ...t, values: [] })), total, page, limit, totalPages: Math.ceil(total / limit) });
const placeholders = taskIds.map(() => '?').join(',');
const values = await db.prepare(
`SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id IN (${placeholders})`
).all(...taskIds);
const result = tasks.map(t => ({
...t,
values: values.filter(v => v.task_id === t.id)
}));
res.json({ tasks: result, total, page, limit, totalPages: Math.ceil(total / limit) });
});
module.exports = router;

119
backend/routes/templates.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* Templates routes module.
*
* Punkt 8: Uses transactions for template updates (delete+insert steps).
* Punkt 6: Uses better-sqlite3 synchronous API.
*/
const express = require('express');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const { validate, createTemplateSchema, updateTemplateSchema } = require('../middleware/validation');
const router = express.Router();
router.use(authMiddleware);
// List templates
router.get('/', async (req, res) => {
const templates = await db.prepare('SELECT * FROM templates ORDER BY id DESC').all();
if (templates.length === 0) return res.json([]);
const templateIds = templates.map(t => t.id).filter(id => Number.isInteger(id));
if (templateIds.length === 0) return res.json(templates.map(t => ({ ...t, steps: [] })));
const placeholders = templateIds.map(() => '?').join(',');
const steps = await db.prepare(`SELECT * FROM template_steps WHERE template_id IN (${placeholders}) ORDER BY step_order ASC`).all(...templateIds);
const result = templates.map(t => ({
...t,
steps: steps.filter(s => s.template_id === t.id)
}));
res.json(result);
});
// Create template (admin only) - Punkt 8: Transaction
router.post('/', adminMiddleware, validate(createTemplateSchema), async (req, res) => {
const { name, description, is_assignable, allows_file_upload, ad_create, steps } = req.validatedBody;
const assignable = is_assignable ? 1 : 0;
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const insertTemplate = db.prepare('INSERT INTO templates (name, description, is_assignable, allows_file_upload, ad_create) VALUES (?, ?, ?, ?, ?)');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for template + steps
const createTemplate = db.transaction(async () => {
const info = await insertTemplate.run(name, description, assignable, fileUpload, adCreate);
const templateId = info.lastInsertRowid;
for (const [idx, step] of steps.entries()) {
await insertStep.run(
templateId, step.page_num || 1, step.label, step.type, idx + 1,
step.email_domain || null, step.email_source_fields || null,
step.dropdown_options || null, step.ad_field || null,
step.hidden ? 1 : 0, step.ad_prefix || null
);
}
return templateId;
});
try {
const templateId = await createTemplate();
auditLog(req.user?.id, 'create_template', 'template', templateId, `Created template: ${name}`);
res.status(201).json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update template (admin only) - Punkt 8: Transaction
router.put('/:id', adminMiddleware, validate(updateTemplateSchema), async (req, res) => {
const templateId = parseInt(req.params.id);
const { name, description, is_assignable, allows_file_upload, ad_create, steps } = req.validatedBody;
const assignable = is_assignable ? 1 : 0;
const fileUpload = allows_file_upload ? 1 : 0;
const adCreate = ad_create ? 1 : 0;
const updateTemplate = db.prepare('UPDATE templates SET name = ?, description = ?, is_assignable = ?, allows_file_upload = ?, ad_create = ? WHERE id = ?');
const deleteSteps = db.prepare('DELETE FROM template_steps WHERE template_id = ?');
const insertStep = db.prepare('INSERT INTO template_steps (template_id, page_num, label, type, step_order, email_domain, email_source_fields, dropdown_options, ad_field, hidden, ad_prefix) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
// Punkt 8: Transaction for update + delete old steps + insert new steps
const updateTemplateTransaction = db.transaction(async () => {
const info = await updateTemplate.run(name, description, assignable, fileUpload, adCreate, templateId);
if (info.changes === 0) throw new Error('NOT_FOUND');
await deleteSteps.run(templateId);
for (const [idx, step] of steps.entries()) {
await insertStep.run(
templateId, step.page_num || 1, step.label, step.type, idx + 1,
step.email_domain || null, step.email_source_fields || null,
step.dropdown_options || null, step.ad_field || null,
step.hidden ? 1 : 0, step.ad_prefix || null
);
}
});
try {
await updateTemplateTransaction();
auditLog(req.user?.id, 'update_template', 'template', templateId, `Updated template: ${name}`);
res.json({ id: templateId, name, description, is_assignable: !!assignable, allows_file_upload: !!fileUpload, ad_create: !!adCreate, steps });
} catch (err) {
if (err.message === 'NOT_FOUND') return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Delete template (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const templateId = parseInt(req.params.id);
const info = await db.prepare('DELETE FROM templates WHERE id = ?').run(templateId);
if (info.changes === 0) return res.status(404).json({ error: 'Vorlage nicht gefunden.' });
auditLog(req.user?.id, 'delete_template', 'template', templateId, null);
res.json({ message: 'Vorlage gelöscht.' });
});
module.exports = router;

65
backend/routes/upload.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* File upload routes module.
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { authMiddleware } = require('../middleware/auth');
const { uploadLimiter } = require('../middleware/rateLimit');
const { auditLog } = require('../auditLog');
const router = express.Router();
// File upload setup
const uploadDir = path.join(__dirname, '..', 'data', 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// VULN-08/09: Secure file upload
const ALLOWED_MIMES = ['application/pdf', 'image/png', 'image/jpeg', 'image/gif', 'text/plain', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const ALLOWED_EXTS = ['.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt', '.doc', '.docx'];
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => {
const safeName = path.basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
const ext = path.extname(safeName).toLowerCase();
const safeExt = ALLOWED_EXTS.includes(ext) ? ext : '.bin';
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + '-' + safeName.replace(/\.[^.]+$/, '') + safeExt);
},
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (ALLOWED_MIMES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Dateityp nicht erlaubt. Erlaubt: PDF, PNG, JPG, GIF, TXT, DOC, DOCX.'));
}
},
});
// P12: Serve uploads as attachments (prevent XSS) - requires authentication
router.use('/uploads', authMiddleware, express.static(uploadDir, {
setHeaders: (res) => {
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('X-Content-Type-Options', 'nosniff');
},
}));
// Upload endpoint - Punkt 23: Rate limited per user
router.post('/', authMiddleware, uploadLimiter, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen.' });
}
const fileUrl = '/uploads/' + req.file.filename;
auditLog(req.user?.id, 'file_upload', null, null, `Uploaded: ${req.file.filename} (${req.file.size} bytes)`);
res.json({ filename: req.file.filename, originalname: req.file.originalname, url: fileUrl, size: req.file.size });
});
module.exports = router;

172
backend/routes/users.js Normal file
View File

@@ -0,0 +1,172 @@
/**
* Users routes module.
*
* Uses better-sqlite3 synchronous API (Punkt 6).
* Proper authorization checks (Punkt 4).
*/
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { auditLog } = require('../auditLog');
const { authMiddleware, adminMiddleware, invalidateUserSessions } = require('../middleware/auth');
const { validate, validateQuery, createUserSchema, updateUserSchema, paginationSchema } = require('../middleware/validation');
const router = express.Router();
// Apply auth to all user routes
router.use(authMiddleware);
// List users (admin only) - with server-side pagination (Punkt 16: bounded limits)
router.get('/', adminMiddleware, validateQuery(paginationSchema), async (req, res) => {
const { page, limit } = req.validatedQuery;
const offset = (page - 1) * limit;
const search = req.query.search;
let whereClause = '';
const params = [];
if (search) {
whereClause = ' WHERE LOWER(email) LIKE LOWER(?) OR LOWER(name) LIKE LOWER(?) OR LOWER(role) LIKE LOWER(?) OR LOWER(COALESCE(username, \'\')) LIKE LOWER(?)';
// P10: Escape LIKE wildcards in search pattern to prevent unintended matching
const escapedSearch = String(search).replace(/[%_\\]/g, '\\$&');
const searchPattern = `%${escapedSearch}%`;
params.push(searchPattern, searchPattern, searchPattern, searchPattern);
} else {
whereClause = ' WHERE status = \'aktiv\'';
}
const countSql = 'SELECT COUNT(*) as total FROM users' + whereClause;
const dataSql = 'SELECT id, email, name, role, status, source, username FROM users' + whereClause + ' ORDER BY id ASC LIMIT ? OFFSET ?';
const countRow = await db.prepare(countSql).get(...params);
const rows = await db.prepare(dataSql).all(...params, limit, offset);
const total = countRow?.total || 0;
res.json({ users: rows || [], total, page, limit, totalPages: Math.ceil(total / limit) });
});
// Create user (admin only)
router.post('/', adminMiddleware, validate(createUserSchema), async (req, res) => {
const { email, password, name, role, status } = req.validatedBody;
try {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
const info = await db.prepare('INSERT INTO users (email, password, name, role, status, source) VALUES (?, ?, ?, ?, ?, \'local\')').run(email, hash, name, role, status);
auditLog(req.user?.id, 'create_user', 'user', info.lastInsertRowid, `Created user: ${email}`);
res.status(201).json({ id: info.lastInsertRowid, email, name, role, status, source: 'local' });
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
res.status(500).json({ error: 'Interner Serverfehler.' });
}
});
// Update user
router.put('/:id', validate(updateUserSchema), async (req, res) => {
const userId = parseInt(req.params.id);
const { email, name, password, role, status, current_password } = req.validatedBody;
// VULN-04: Authorization check - only admin or self (with restrictions)
const isSelf = req.user.id === userId;
const isAdmin = req.user.role === 'admin';
if (!isAdmin && !isSelf) {
return res.status(403).json({ error: 'Keine Berechtigung, diesen Nutzer zu bearbeiten.' });
}
// Non-admins may NOT change role or status (privilege escalation prevention)
if (!isAdmin) {
delete req.validatedBody.role;
delete req.validatedBody.status;
}
// P7: Non-admins changing their own password must verify the current password
if (!isAdmin && isSelf && password && password.trim()) {
if (!current_password) {
return res.status(400).json({ error: 'Aktuelles Passwort ist erforderlich, um das Passwort zu ändern.' });
}
const userRow = await db.prepare('SELECT password FROM users WHERE id = ?').get(userId);
if (!userRow) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
let currentMatch = false;
if (userRow.password.startsWith('$2a$') || userRow.password.startsWith('$2b$')) {
currentMatch = bcrypt.compareSync(current_password, userRow.password);
} else {
currentMatch = userRow.password === current_password;
}
if (!currentMatch) {
return res.status(403).json({ error: 'Aktuelles Passwort ist falsch.' });
}
}
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
// AD users: only role and status can be changed
if (user.source === 'ad') {
const finalRole = role || user.role;
const finalStatus = status || user.status;
if (role && !['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'Rolle muss "admin" oder "user" sein.' });
}
if (status && !['aktiv', 'inaktiv'].includes(status)) {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
}
await db.prepare('UPDATE users SET role = ?, status = ? WHERE id = ?').run(finalRole, finalStatus, userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated AD user role/status`);
return res.json({ id: userId, email: user.email, name: user.name, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
// Local users: full edit
if (!email) {
return res.status(400).json({ error: 'E-Mail ist erforderlich.' });
}
if (role && !['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'Rolle muss "admin" oder "user" sein.' });
}
if (status && !['aktiv', 'inaktiv'].includes(status)) {
return res.status(400).json({ error: 'Status muss "aktiv" oder "inaktiv" sein.' });
}
const finalName = name !== undefined ? name : (user.name || '');
const finalRole = role || user.role;
const finalStatus = status || user.status;
if (password && password.trim()) {
const hash = bcrypt.hashSync(password, 12); // Punkt 10: 12 rounds per OWASP
try {
await db.prepare('UPDATE users SET email = ?, name = ?, password = ?, role = ?, status = ? WHERE id = ?').run(email, finalName, hash, finalRole, finalStatus, userId);
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
throw err;
}
// VULN-13: Invalidate all sessions for this user after password change
await invalidateUserSessions(userId);
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user with new password (sessions invalidated)`);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
} else {
try {
await db.prepare('UPDATE users SET email = ?, name = ?, role = ?, status = ? WHERE id = ?').run(email, finalName, finalRole, finalStatus, userId);
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'E-Mail bereits vergeben.' });
}
throw err;
}
auditLog(req.user?.id, 'update_user', 'user', userId, `Updated local user`);
res.json({ id: userId, email, name: finalName, role: finalRole, status: finalStatus, source: user.source, username: user.username });
}
});
// Delete user (admin only)
router.delete('/:id', adminMiddleware, async (req, res) => {
const userId = parseInt(req.params.id);
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
if (user.source === 'ad') {
return res.status(403).json({ error: 'AD-Nutzer koennen nicht geloescht werden. Bitte im Active Directory entfernen.' });
}
const info = await db.prepare('DELETE FROM users WHERE id = ?').run(userId);
if (info.changes === 0) return res.status(404).json({ error: 'Nutzer nicht gefunden.' });
auditLog(req.user?.id, 'delete_user', 'user', userId, `Deleted user: ${user.email}`);
res.json({ message: 'Nutzer geloescht.' });
});
module.exports = router;

215
backend/server.js Normal file
View File

@@ -0,0 +1,215 @@
/**
* Workflow Portal Backend - Modular Architecture
*
* Punkt 1: Modularized from monolithic server.js into route modules
* Punkt 2: Removed unused Prisma (no longer needed)
* Punkt 3: Proper migration tracking via _migrations table
* Punkt 4: Session tokens hashed with SHA-256
* Punkt 5: Register returns correct 'inaktiv' status
* Punkt 6: better-sqlite3 (synchronous, no callback hell)
* Punkt 7: Single aggregated stats query
* Punkt 8: Transactions for template updates and task creation
* Punkt 9: LDAP sync lock
* Punkt 10: express-async-errors for global error handling
* Punkt 19: Configurable CORS via env
* Punkt 22: Prisma removed (was unused)
* Punkt 23: User-level rate limiting
*/
const express = require('express');
require('express-async-errors');
const cors = require('cors');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const path = require('path');
// Initialize database (better-sqlite3, WAL mode, migrations)
const db = require('./db');
const { initDatabase } = require('./migrations');
// Auth middleware
const { authMiddleware, csrfMiddleware } = require('./middleware/auth');
// Rate limiters
const rateLimit = require('express-rate-limit');
const { apiLimiter } = require('./middleware/rateLimit');
// Route modules
const authRoutes = require('./routes/auth');
const usersRoutes = require('./routes/users');
const templatesRoutes = require('./routes/templates');
const tasksRoutes = require('./routes/tasks');
const adRoutes = require('./routes/ad');
const statsRoutes = require('./routes/stats');
const uploadRoutes = require('./routes/upload');
// LDAP sync
const { startLDAPSync, isLDAPConfigured } = require('./ldapSync');
const app = express();
const PORT = process.env.PORT || 5000;
// Trust proxy for correct IP in rate limiting (Docker/Reverse Proxy)
app.set('trust proxy', 1);
// ============ Security Middleware ============
// P14: Validate CORS_ORIGIN - filter empty/invalid entries before using in CSP
const rawCorsOrigin = process.env.CORS_ORIGIN || '';
const validCorsOrigins = rawCorsOrigin
.split(',')
.map(o => o.trim())
.filter(o => o && /^https?:\/\/.+/.test(o));
const cspConnectSrc = ["'self'", ...validCorsOrigins];
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
connectSrc: cspConnectSrc,
fontSrc: ["'self'", "data:"],
},
},
// P18: HSTS - enforce HTTPS in production
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
crossOriginEmbedderPolicy: false,
}));
// Punkt 19: Configurable CORS via env variable
// Single container: Frontend served from same origin, CORS only needed for external access
const allowedOrigins = validCorsOrigins.length > 0
? validCorsOrigins
: ['http://localhost:5000', 'http://localhost:5173', 'http://127.0.0.1:5000', 'http://127.0.0.1:5173'];
app.use(cors({ origin: allowedOrigins, credentials: true }));
// Punkt 14: Body-Size-Limit to prevent DoS via large payloads
app.use(express.json({ limit: process.env.BODY_LIMIT || '1mb' }));
// Punkt 8: Cookie-Parser für HttpOnly-Cookie Auth
app.use(cookieParser());
// P4: CSRF protection for state-changing requests (Double-Submit-Cookie)
// Skip CSRF check for login/register (no session yet, no CSRF token available)
app.use('/api', (req, res, next) => {
if (req.path.startsWith('/auth/login') || req.path.startsWith('/auth/register') || req.path.startsWith('/v1/auth/login') || req.path.startsWith('/v1/auth/register')) {
return next();
}
csrfMiddleware(req, res, next);
});
// ============ Rate Limiting ============
app.use('/api', apiLimiter);
// ============ Health Check (Punkt 6) ============
// V5: Rate-limit /health to prevent DoS/amplification abuse
const healthLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Health-Check-Anfragen.' },
});
app.get('/health', healthLimiter, (req, res) => {
res.json({ status: 'ok', uptime: Math.floor(process.uptime()), timestamp: new Date().toISOString() });
});
// ============ Auth Middleware for all /api/ routes except /api/auth/ ============
app.use('/api', (req, res, next) => {
// Skip auth for login, register, and status endpoints
if (req.path.startsWith('/auth/') || req.path === '/ad/status') {
return next();
}
authMiddleware(req, res, next);
});
// ============ Routes (Punkt 13: API-Versionierung /api/v1) ============
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', usersRoutes);
app.use('/api/v1/templates', templatesRoutes);
app.use('/api/v1/tasks', tasksRoutes);
app.use('/api/v1/ad', adRoutes);
app.use('/api/v1', statsRoutes);
app.use('/api/v1/upload', uploadRoutes);
// ============ Backward Compatibility: /api/ → /api/v1/ ============
app.use('/api/auth', authRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/templates', templatesRoutes);
app.use('/api/tasks', tasksRoutes);
app.use('/api/ad', adRoutes);
app.use('/api', statsRoutes);
app.use('/api/upload', uploadRoutes);
// ============ Serve Frontend (Single Container) ============
const frontendPath = path.join(__dirname, 'frontend', 'dist');
app.use(express.static(frontendPath));
// SPA fallback: serve index.html for all non-API routes
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path.startsWith('/health')) return next();
res.sendFile(path.join(frontendPath, 'index.html'));
});
// ============ Global Error Handler (Punkt 10) ============
app.use((err, req, res, next) => {
console.error('[ERROR]', req.method, req.path, '-', err.message);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Interner Serverfehler.' });
});
// ============ Initialize & Start ============
async function start() {
try {
await initDatabase();
startLDAPSync(db);
const server = app.listen(PORT, () => {
console.log(`Workflow Portal Backend gestartet auf Port ${PORT}`);
});
// ============ Graceful Shutdown (Punkt 4) ============
function gracefulShutdown(signal) {
console.log(`\n[SHUTDOWN] ${signal} empfangen, fahre herunter...`);
// Stop LDAP sync timer
const { stopLDAPSync } = require('./ldapSync');
stopLDAPSync();
// Stop accepting new connections
server.close(async () => {
console.log('[SHUTDOWN] HTTP-Server gestoppt.');
// Close database connection
try {
if (db._type === 'postgres') {
await db.close();
} else {
db.close();
}
console.log('[SHUTDOWN] Datenbankverbindung geschlossen.');
} catch (err) {
console.error('[SHUTDOWN] Fehler beim Schließen der Datenbank:', err.message);
}
console.log('[SHUTDOWN] Erfolgreich heruntergefahren.');
process.exit(0);
});
// Force shutdown after 10 seconds if connections don't close
setTimeout(() => {
console.error('[SHUTDOWN] Erzwinge Shutdown nach Timeout.');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
} catch (err) {
console.error('[FATAL] Start fehlgeschlagen:', err.message);
process.exit(1);
}
}
start();

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

91
docker-compose.yml Normal file
View File

@@ -0,0 +1,91 @@
services:
app:
build: .
ports:
- "5000:5000"
volumes:
- ./backend/data:/app/data
environment:
- LDAP_SERVER=${LDAP_SERVER:-}
- LDAP_PORT=${LDAP_PORT:-389}
- LDAP_SEARCH_BASE=${LDAP_SEARCH_BASE:-}
- LDAP_DOMAIN=${LDAP_DOMAIN:-}
- LDAP_IGNORE_CERT_ERRORS=${LDAP_IGNORE_CERT_ERRORS:-false}
- LDAP_BIND_USER=${LDAP_BIND_USER:-}
- LDAP_BIND_PASSWORD=${LDAP_BIND_PASSWORD:-}
- LDAP_SYNC_INTERVAL=${LDAP_SYNC_INTERVAL:-300000}
- LDAP_FILTER=${LDAP_FILTER:-}
- LDAP_ATTRIBUTES=${LDAP_ATTRIBUTES:-mail,displayName,memberOf,distinguishedName,sAMAccountName}
- LDAP_CREATE_OU=${LDAP_CREATE_OU:-}
- LDAP_UPN_SUFFIX=${LDAP_UPN_SUFFIX:-}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@workflow.local}
- ADMIN_INIT_PASSWORD=${ADMIN_INIT_PASSWORD:-}
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5000}
- NODE_ENV=${NODE_ENV:-production}
# Punkt 4: PostgreSQL (auto-started)
- DATABASE_URL=postgresql://${POSTGRES_USER:-workflow}:${POSTGRES_PASSWORD:-workflow}@db:5432/${POSTGRES_DB:-workflow}
# Punkt 9: Session-Limitierung
- SESSION_MAX_PER_USER=${SESSION_MAX_PER_USER:-5}
- SESSION_TTL_HOURS=${SESSION_TTL_HOURS:-168}
# Punkt 12: Account-Lockout
- LOGIN_MAX_ATTEMPTS=${LOGIN_MAX_ATTEMPTS:-5}
- LOGIN_LOCKOUT_MINUTES=${LOGIN_LOCKOUT_MINUTES:-15}
# Punkt 14: Body-Size-Limit
- BODY_LIMIT=${BODY_LIMIT:-1mb}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
# Punkt 4: PostgreSQL Database (auto-started)
db:
image: postgres:16-alpine
environment:
- POSTGRES_DB=${POSTGRES_DB:-workflow}
- POSTGRES_USER=${POSTGRES_USER:-workflow}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-workflow}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-workflow} -d ${POSTGRES_DB:-workflow}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# Punkt 5: DB-Backup Service (auto-started)
db-backup:
image: postgres:16-alpine
environment:
- POSTGRES_USER=${POSTGRES_USER:-workflow}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-workflow}
- POSTGRES_DB=${POSTGRES_DB:-workflow}
- BACKUP_INTERVAL_HOURS=${BACKUP_INTERVAL_HOURS:-6}
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-30}
- PGHOST=db
- PGUSER=${POSTGRES_USER:-workflow}
- PGPASSWORD=${POSTGRES_PASSWORD:-workflow}
volumes:
- ./backups:/backups
entrypoint: |
/bin/sh -c "
echo '[Backup] Starting backup service (interval: '$$BACKUP_INTERVAL_HOURS'h, retention: '$$BACKUP_RETENTION_DAYS'd)';
while true; do
TIMESTAMP=$$(date +%Y%m%d_%H%M%S);
FILENAME=/backups/workflow_$$TIMESTAMP.sql.gz;
echo '[Backup] Creating backup: '$$FILENAME;
pg_dump -h db -U $$PGUSER $$POSTGRES_DB | gzip > $$FILENAME;
echo '[Backup] Done: '$$FILENAME;
find /backups -name 'workflow_*.sql.gz' -mtime +$$BACKUP_RETENTION_DAYS -delete;
echo '[Backup] Cleaned old backups (older than '$$BACKUP_RETENTION_DAYS' days)';
SLEEP_SECONDS=$$((BACKUP_INTERVAL_HOURS * 3600));
sleep $$SLEEP_SECONDS;
done
"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
pgdata:

1
frontend/.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

3
frontend/.env.example Normal file
View File

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

14
frontend/Dockerfile Normal file
View File

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

19
frontend/index.html Normal file
View File

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

2176
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
frontend/package.json Normal file
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useAuth } from '../context/AuthContext';
export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const pageCount = [...new Set(template.steps?.map((s) => s.page_num) || [])].length;
return (
<div className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="font-bold text-base truncate">{template.name}</h3>
{pageCount > 1 && (
<span className="badge badge-ghost badge-sm">{pageCount} Seiten</span>
)}
</div>
<p className="text-xs opacity-70 line-clamp-1 mt-1">
{template.description || 'Keine Beschreibung'}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{isAdmin && (
<>
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
Bearbeiten
</button>
<button className="btn btn-ghost btn-sm text-error" onClick={() => onDelete(template.id)} title="Löschen">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import { useToast } from '../components/Toast';
import FillModal from '../components/FillModal';
import { apiFetch } from '../utils/api';
export default function Dashboard({ onNavigate }) {
const { user } = useAuth();
const toast = useToast();
const isAdmin = user?.role === 'admin';
const [templates, setTemplates] = useState([]);
const [stats, setStats] = useState(null);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [fillTemplate, setFillTemplate] = useState(null);
const fetchTemplates = useCallback(async () => {
try {
const res = await apiFetch('/templates');
const data = await res.json();
setTemplates(data);
} catch (e) {
console.error('Fehler beim Laden der Vorlagen:', e);
} finally {
setLoading(false);
}
}, []);
const fetchStats = useCallback(async () => {
if (!isAdmin) return;
try {
const res = await apiFetch('/stats');
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch (e) {
console.error('Fehler beim Laden der Statistiken:', e);
}
}, [isAdmin]);
useEffect(() => {
fetchTemplates();
fetchStats();
const interval = setInterval(fetchTemplates, 30000);
return () => clearInterval(interval);
}, [fetchTemplates, fetchStats]);
// Alle Vorlagen sind für jeden User sichtbar (nicht mehr nach is_assignable gefiltert)
const topTemplates = stats?.topTemplates || [];
const filtered = templates.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
(t.description || '').toLowerCase().includes(search.toLowerCase())
);
const handleSubmitTask = async (payload) => {
const res = await apiFetch('/tasks', {
method: 'POST',
body: JSON.stringify({ ...payload, user_id: user.id }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Fehler beim Absenden');
}
setFillTemplate(null);
toast.success('Aufgabe erfolgreich abgesendet!');
fetchStats();
};
if (loading) {
return (
<div className="p-4 md:p-6 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="static-skeleton h-24 w-full"></div>
<div className="static-skeleton h-24 w-full"></div>
<div className="static-skeleton h-24 w-full"></div>
<div className="static-skeleton h-24 w-full"></div>
</div>
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="static-skeleton h-32 w-full"></div>
<div className="static-skeleton h-32 w-full"></div>
<div className="static-skeleton h-32 w-full"></div>
</div>
</div>
);
}
return (
<div className="p-4 md:p-6">
{/* KPI-Sektion: 4 Statistik-Karten mit Delta + Fortschrittsbalken */}
{isAdmin && stats && (
<section aria-label="Kennzahlen" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
{/* StatCard: Offene Aufgaben */}
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
<div className="card-body p-5 space-y-3">
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Offene Aufgaben</span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums">{stats.openTasks}</span>
{stats.totalTasks > 0 && (
<span className="text-xs font-medium text-primary">
{Math.round((stats.openTasks / stats.totalTasks) * 100)}%
</span>
)}
</div>
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
<div className="h-full bg-primary transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.openTasks / stats.totalTasks) * 100 : 0}%` }} />
</div>
</div>
</div>
{/* StatCard: Abgeschlossen */}
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
<div className="card-body p-5 space-y-3">
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Abgeschlossen</span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums">{stats.completedTasks}</span>
{stats.totalTasks > 0 && (
<span className="text-xs font-medium text-success">
{Math.round((stats.completedTasks / stats.totalTasks) * 100)}%
</span>
)}
</div>
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
<div className="h-full bg-success transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.completedTasks / stats.totalTasks) * 100 : 0}%` }} />
</div>
</div>
</div>
{/* StatCard: Aktive Nutzer */}
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
<div className="card-body p-5 space-y-3">
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Aktive Nutzer</span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums">{stats.activeUsers}</span>
{stats.totalUsers > 0 && (
<span className="text-xs font-medium text-secondary">
von {stats.totalUsers}
</span>
)}
</div>
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
<div className="h-full bg-secondary transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.activeUsers / stats.totalUsers) * 100 : 0}%` }} />
</div>
</div>
</div>
{/* StatCard: AD-Nutzer */}
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
<div className="card-body p-5 space-y-3">
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">AD-Nutzer</span>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tabular-nums">{stats.adUsers}</span>
</div>
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
<div className="h-full bg-warning transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.adUsers / stats.totalUsers) * 100 : 0}%` }} />
</div>
</div>
</div>
</section>
)}
{/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* Linke Spalte: Top-Vorlagen Rangliste */}
{isAdmin && topTemplates.length > 0 && (
<section className="lg:col-span-4">
<div className="flex items-center justify-between mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">Meistgenutzte Vorlagen</h2>
</div>
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
<div className="divide-y divide-base-300">
{topTemplates.map((t) => (
<div
key={t.id}
className="p-4 flex items-center justify-between hover:bg-base-300/50 transition-colors"
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">{t.name}</span>
<span className="text-xs opacity-60">{t.task_count} Aufgaben</span>
</div>
<span className={`text-[10px] font-mono py-1 px-2 rounded shrink-0 ml-3 ${
t.rank === 1 || t === topTemplates[0]
? 'bg-primary/10 text-primary border border-primary/20'
: 'bg-base-300 text-base-content/60'
}`}>
TOP {topTemplates.indexOf(t) + 1}
</span>
</div>
))}
</div>
</div>
</section>
)}
{/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */}
<section className={isAdmin && topTemplates.length > 0 ? "lg:col-span-8" : "lg:col-span-12"}>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 h-[38px]">
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
<div className="form-control w-full sm:w-72">
<input
type="text"
placeholder="Vorlagen durchsuchen..."
className="input input-bordered w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{templates.length === 0 ? (
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
<p className="text-sm opacity-60">Keine Vorlagen vorhanden.</p>
</div>
) : filtered.length === 0 ? (
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{filtered.map((tpl) => {
const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length;
return (
<article key={tpl.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-all rounded-lg p-4 flex flex-col justify-between min-h-32">
<div>
{pageCount > 1 && (
<div className="flex justify-end mb-2">
<span className="text-[10px] opacity-50 font-mono">{pageCount} Seiten</span>
</div>
)}
<h3 className="text-base font-medium line-clamp-2 leading-tight">{tpl.name}</h3>
</div>
<div className="flex items-center justify-end mt-3">
<button
className="btn btn-primary btn-sm"
onClick={() => setFillTemplate(tpl)}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Starten
</button>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
{fillTemplate && (
<FillModal
template={fillTemplate}
onSubmit={handleSubmitTask}
onClose={() => setFillTemplate(null)}
/>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More