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:
33
.dockerignore
Normal file
33
.dockerignore
Normal 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
49
.env.example
Normal 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
33
.gitignore
vendored
Normal 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
30
Dockerfile
Normal 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
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
data
|
||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal 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
18
backend/auditLog.js
Normal 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
154
backend/db.js
Normal 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
444
backend/ldapOperations.js
Normal 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
240
backend/ldapSync.js
Normal 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
170
backend/middleware/auth.js
Normal 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
|
||||
};
|
||||
46
backend/middleware/rateLimit.js
Normal file
46
backend/middleware/rateLimit.js
Normal 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 };
|
||||
209
backend/middleware/validation.js
Normal file
209
backend/middleware/validation.js
Normal 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
202
backend/migrations.js
Normal 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
1974
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/package.json
Normal file
27
backend/package.json
Normal 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
124
backend/routes/ad.js
Normal 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
147
backend/routes/auth.js
Normal 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
83
backend/routes/stats.js
Normal 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
216
backend/routes/tasks.js
Normal 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
119
backend/routes/templates.js
Normal 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
65
backend/routes/upload.js
Normal 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
172
backend/routes/users.js
Normal 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
215
backend/server.js
Normal 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();
|
||||
BIN
backups/workflow_20260727_123700.sql.gz
Normal file
BIN
backups/workflow_20260727_123700.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260727_123743.sql.gz
Normal file
BIN
backups/workflow_20260727_123743.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260727_123848.sql.gz
Normal file
BIN
backups/workflow_20260727_123848.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260728_060307.sql.gz
Normal file
BIN
backups/workflow_20260728_060307.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260728_061026.sql.gz
Normal file
BIN
backups/workflow_20260728_061026.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260728_111622.sql.gz
Normal file
BIN
backups/workflow_20260728_111622.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260728_171621.sql.gz
Normal file
BIN
backups/workflow_20260728_171621.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260728_231619.sql.gz
Normal file
BIN
backups/workflow_20260728_231619.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260729_051618.sql.gz
Normal file
BIN
backups/workflow_20260729_051618.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260729_111615.sql.gz
Normal file
BIN
backups/workflow_20260729_111615.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260730_121825.sql.gz
Normal file
BIN
backups/workflow_20260730_121825.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260730_181824.sql.gz
Normal file
BIN
backups/workflow_20260730_181824.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_001822.sql.gz
Normal file
BIN
backups/workflow_20260731_001822.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_061821.sql.gz
Normal file
BIN
backups/workflow_20260731_061821.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_070100.sql.gz
Normal file
BIN
backups/workflow_20260731_070100.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_070705.sql.gz
Normal file
BIN
backups/workflow_20260731_070705.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_070759.sql.gz
Normal file
BIN
backups/workflow_20260731_070759.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_082025.sql.gz
Normal file
BIN
backups/workflow_20260731_082025.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_084457.sql.gz
Normal file
BIN
backups/workflow_20260731_084457.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_084716.sql.gz
Normal file
BIN
backups/workflow_20260731_084716.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_084833.sql.gz
Normal file
BIN
backups/workflow_20260731_084833.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260731_144831.sql.gz
Normal file
BIN
backups/workflow_20260731_144831.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260803_075323.sql.gz
Normal file
BIN
backups/workflow_20260803_075323.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260803_135322.sql.gz
Normal file
BIN
backups/workflow_20260803_135322.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260804_085755.sql.gz
Normal file
BIN
backups/workflow_20260804_085755.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260804_145754.sql.gz
Normal file
BIN
backups/workflow_20260804_145754.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260804_205753.sql.gz
Normal file
BIN
backups/workflow_20260804_205753.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260805_025751.sql.gz
Normal file
BIN
backups/workflow_20260805_025751.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260805_085748.sql.gz
Normal file
BIN
backups/workflow_20260805_085748.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260805_145746.sql.gz
Normal file
BIN
backups/workflow_20260805_145746.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260805_205745.sql.gz
Normal file
BIN
backups/workflow_20260805_205745.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260806_025743.sql.gz
Normal file
BIN
backups/workflow_20260806_025743.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260806_085741.sql.gz
Normal file
BIN
backups/workflow_20260806_085741.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260810_062308.sql.gz
Normal file
BIN
backups/workflow_20260810_062308.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260810_122307.sql.gz
Normal file
BIN
backups/workflow_20260810_122307.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260810_182306.sql.gz
Normal file
BIN
backups/workflow_20260810_182306.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260811_002305.sql.gz
Normal file
BIN
backups/workflow_20260811_002305.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260811_062304.sql.gz
Normal file
BIN
backups/workflow_20260811_062304.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260811_122302.sql.gz
Normal file
BIN
backups/workflow_20260811_122302.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260811_182301.sql.gz
Normal file
BIN
backups/workflow_20260811_182301.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260812_002300.sql.gz
Normal file
BIN
backups/workflow_20260812_002300.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260812_062257.sql.gz
Normal file
BIN
backups/workflow_20260812_062257.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260812_122256.sql.gz
Normal file
BIN
backups/workflow_20260812_122256.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260820_100335.sql.gz
Normal file
BIN
backups/workflow_20260820_100335.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260820_160337.sql.gz
Normal file
BIN
backups/workflow_20260820_160337.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260820_220336.sql.gz
Normal file
BIN
backups/workflow_20260820_220336.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260821_040335.sql.gz
Normal file
BIN
backups/workflow_20260821_040335.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260821_100334.sql.gz
Normal file
BIN
backups/workflow_20260821_100334.sql.gz
Normal file
Binary file not shown.
BIN
backups/workflow_20260821_160332.sql.gz
Normal file
BIN
backups/workflow_20260821_160332.sql.gz
Normal file
Binary file not shown.
91
docker-compose.yml
Normal file
91
docker-compose.yml
Normal 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
1
frontend/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
3
frontend/.env.example
Normal file
3
frontend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Frontend Environment Variables
|
||||
# API base URL for backend (default: http://localhost:5000/api)
|
||||
VITE_API_BASE=http://localhost:5000/api
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# VULN-17: Multi-stage build for production (no dev server in prod)
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g serve@14
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 5173
|
||||
CMD ["serve", "-s", "dist", "-l", "5173"]
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow Portal</title>
|
||||
<style>
|
||||
/* FOUC prevention: hide root until CSS is loaded */
|
||||
#root:not(:has(*)) { display: none; }
|
||||
/* Font-display swap to prevent font layout shift */
|
||||
@font-face { font-family: system-ui; src: local('system-ui'); font-display: swap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2176
frontend/package-lock.json
generated
Normal file
2176
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "workflow-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Workflow Portal Frontend",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"daisyui": "^4.4.19",
|
||||
"postcss": "^8.5.26",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
143
frontend/src/App.jsx
Normal file
143
frontend/src/App.jsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { ToastProvider, useToast } from './components/Toast';
|
||||
import { setGlobalErrorHandler } from './utils/api';
|
||||
import AuthModal from './components/AuthModal';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import TemplatesPage from './pages/TemplatesPage';
|
||||
import TasksPage from './pages/TasksPage';
|
||||
import UsersPage from './pages/UsersPage';
|
||||
import AuditLogPage from './pages/AuditLogPage';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
// Punkt 12: React Query Client mit zentralem Error-Handling
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000, // 30s stale time (replaces manual 30s polling)
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AppContent() {
|
||||
const { user, loading } = useAuth();
|
||||
const toast = useToast();
|
||||
const [activeTab, setActiveTab] = useState(user ? (user.role === 'admin' ? 'dashboard' : 'dashboard') : 'dashboard');
|
||||
|
||||
// Punkt 11: Global error handler for apiFetch
|
||||
useEffect(() => {
|
||||
setGlobalErrorHandler((type, message) => {
|
||||
if (type === 'network' || type === 'server') {
|
||||
toast.error(message);
|
||||
} else if (type === 'rateLimit') {
|
||||
toast.error(message);
|
||||
}
|
||||
});
|
||||
}, [toast]);
|
||||
|
||||
// #13: Map tab IDs to display labels for mobile navbar indicator
|
||||
const tabLabels = {
|
||||
dashboard: 'Vorlagen',
|
||||
templates: 'Vorlageneditor',
|
||||
tasks: 'Aufgaben',
|
||||
users: 'Nutzerverwaltung',
|
||||
auditlog: 'Audit-Log',
|
||||
};
|
||||
|
||||
// Punkt 1: Immediate tab switch - no skeleton/transition delay to avoid bounce
|
||||
const handleTabChange = (newTab) => {
|
||||
if (newTab === activeTab) return;
|
||||
setActiveTab(newTab);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-base-100">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <AuthModal />;
|
||||
}
|
||||
|
||||
// Weg A: Render all pages once and keep them in DOM, only toggle visibility.
|
||||
// This prevents re-mounting and re-loading data on every tab switch.
|
||||
// Bug 7: Non-admins only get one Dashboard instance, not multiple.
|
||||
const isAdmin = user.role === 'admin';
|
||||
const renderPages = () => (
|
||||
<>
|
||||
{/* Dashboard is always rendered for all users */}
|
||||
<div className={activeTab === 'dashboard' ? 'block' : 'hidden'}>
|
||||
<Dashboard onNavigate={handleTabChange} />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className={activeTab === 'templates' ? 'block' : 'hidden'}>
|
||||
<TemplatesPage />
|
||||
</div>
|
||||
<div className={activeTab === 'tasks' ? 'block' : 'hidden'}>
|
||||
<TasksPage />
|
||||
</div>
|
||||
<div className={activeTab === 'users' ? 'block' : 'hidden'}>
|
||||
<UsersPage />
|
||||
</div>
|
||||
<div className={activeTab === 'auditlog' ? 'block' : 'hidden'}>
|
||||
<AuditLogPage />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={activeTab === 'dashboard' || (isAdmin && ['templates', 'tasks', 'users', 'auditlog'].includes(activeTab)) ? 'hidden' : 'block'}>
|
||||
<NotFound onGoHome={() => handleTabChange('dashboard')} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<input id="sidebar-drawer" type="checkbox" className="drawer-toggle" />
|
||||
|
||||
<div className="drawer-content flex flex-col">
|
||||
{/* Mobile navbar */}
|
||||
<div className="lg:hidden navbar bg-base-100 shadow-md px-4 sticky top-0 z-30">
|
||||
<label htmlFor="sidebar-drawer" className="btn btn-square btn-ghost drawer-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</label>
|
||||
<span className="ml-2 font-bold">{tabLabels[activeTab] || 'Workflow Portal'}</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto bg-base-100">
|
||||
<ErrorBoundary>
|
||||
<div>
|
||||
{renderPages()}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Sidebar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/AuthModal.jsx
Normal file
145
frontend/src/components/AuthModal.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function AuthModal() {
|
||||
const { login, register } = useAuth();
|
||||
const [mode, setMode] = useState('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === 'login') {
|
||||
await login(email, password);
|
||||
} else {
|
||||
await register(email, password, name);
|
||||
// Punkt 2: Show success message (not as error alert)
|
||||
setSuccess('Account erstellt! Ein Administrator muss dich freischalten, bevor du dich anmelden kannst.');
|
||||
setMode('login');
|
||||
setPassword('');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
setMode(mode === 'login' ? 'register' : 'login');
|
||||
setError('');
|
||||
setSuccess('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div className="card bg-base-100 shadow-md w-full max-w-md">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl justify-center mb-4">
|
||||
{mode === 'login' ? 'Anmelden' : 'Registrieren'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{mode === 'register' && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vollständiger Name"
|
||||
className="input input-bordered w-full"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">E-Mail oder Anmeldename</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="email"
|
||||
placeholder="E-Mail oder AD-Anmeldename"
|
||||
className="input input-bordered w-full"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Passwort</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Passwort eingeben"
|
||||
className="input input-bordered w-full pr-10"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 opacity-50 hover:opacity-100 transition-opacity"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<span className="text-sm">{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn btn-primary w-full ${loading ? 'loading' : ''}`}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
) : mode === 'login' ? 'Anmelden' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="divider text-sm">ODER</div>
|
||||
|
||||
<button className="btn btn-outline btn-sm w-full" onClick={toggleMode}>
|
||||
{mode === 'login' ? 'Neuen Account erstellen' : 'Zurück zur Anmeldung'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
889
frontend/src/components/EditorModal.jsx
Normal file
889
frontend/src/components/EditorModal.jsx
Normal file
@@ -0,0 +1,889 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, ConfirmModal } from './Toast';
|
||||
|
||||
const MAX_STEPS_PER_PAGE = 15;
|
||||
|
||||
// Punkt 8: Vorschau-Komponente (extrahiert wegen JSX-Parsing-Komplexität)
|
||||
function EditorPreview({ name, pages, onClose }) {
|
||||
const pageEntries = Object.entries(pages).sort(([a], [b]) => a - b);
|
||||
return (
|
||||
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal-box max-w-2xl w-11/12 max-h-[85vh]">
|
||||
<h3 className="font-bold text-lg mb-1">Vorschau: {name}</h3>
|
||||
<div className="overflow-y-auto max-h-[60vh] flex flex-col gap-3">
|
||||
{pageEntries.map(([pageNum, steps]) => {
|
||||
if (steps.length === 0) return null;
|
||||
return (
|
||||
<div key={pageNum}>
|
||||
{pageEntries.length > 1 && (
|
||||
<h4 className="font-semibold text-sm opacity-70 mb-2">Seite {pageNum}</h4>
|
||||
)}
|
||||
{steps.map((step, idx) => (
|
||||
<div key={idx} className="bg-base-200 p-3 rounded-lg mb-2">
|
||||
<label className="font-medium text-sm block mb-1">
|
||||
{step.label || '(unbenannt)'}
|
||||
{step.hidden && <span className="badge badge-xs badge-ghost ml-2">Versteckt</span>}
|
||||
{step.ad_field && <span className="badge badge-xs badge-secondary ml-2">AD: {step.ad_field}</span>}
|
||||
</label>
|
||||
{step.type === 'text_input' && (
|
||||
<input type="text" className="input input-bordered input-sm w-full" placeholder={step.label || 'Text eingeben...'} disabled />
|
||||
)}
|
||||
{step.type === 'checkbox' && (
|
||||
<label className="label cursor-pointer justify-start gap-2">
|
||||
<input type="checkbox" className="checkbox checkbox-primary checkbox-sm" disabled />
|
||||
<span className="text-sm">Ja</span>
|
||||
</label>
|
||||
)}
|
||||
{step.type === 'email' && (
|
||||
<input type="text" className="input input-bordered input-sm w-full bg-base-300" disabled value={'auto@' + (step.email_domain || '...')} />
|
||||
)}
|
||||
{step.type === 'dropdown' && (
|
||||
<select className="select select-bordered select-sm w-full" disabled>
|
||||
<option>-- Bitte wählen --</option>
|
||||
{(step.dropdown_options || '').split('|').map((o) => o.trim()).filter((o) => !!o).map((opt, i) => (
|
||||
<option key={i}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{step.type === 'ad_password' && (
|
||||
<input type="text" className="input input-bordered input-sm w-full bg-base-300" disabled value="********" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-sm" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_TYPES = [
|
||||
{ value: 'text_input', label: 'Text-Feld' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
{ value: 'dropdown', label: 'Dropdown' },
|
||||
{ value: 'email', label: 'E-Mail (auto)' },
|
||||
{ value: 'ad_password', label: 'AD-Standardpasswort' },
|
||||
];
|
||||
|
||||
const AD_FIELD_OPTIONS = [
|
||||
{ value: '', label: '— Kein AD-Feld —' },
|
||||
{ value: 'givenName', label: 'Vorname (givenName)' },
|
||||
{ value: 'sn', label: 'Nachname (sn)' },
|
||||
{ value: 'mail', label: 'E-Mail (mail)' },
|
||||
{ value: 'sAMAccountName', label: 'Anmeldename (sAMAccountName)' },
|
||||
{ value: 'displayName', label: 'Anzeigename (displayName)' },
|
||||
{ value: 'department', label: 'Abteilung (department)' },
|
||||
{ value: 'telephoneNumber', label: 'Telefon (telephoneNumber)' },
|
||||
{ value: 'title', label: 'Position (title)' },
|
||||
{ value: 'physicalDeliveryOfficeName', label: 'Büro (physicalDeliveryOfficeName)' },
|
||||
{ value: 'company', label: 'Firma (company)' },
|
||||
{ value: 'description', label: 'Beschreibung (description)' },
|
||||
{ value: 'wWWHomePage', label: 'Webseite (wWWHomePage)' },
|
||||
{ value: 'streetAddress', label: 'Straße (streetAddress)' },
|
||||
{ value: 'postOfficeBox', label: 'Postfach (postOfficeBox)' },
|
||||
{ value: 'l', label: 'Ort (l)' },
|
||||
{ value: 'st', label: 'Bundesland/Kanton (st)' },
|
||||
{ value: 'postalCode', label: 'PLZ (postalCode)' },
|
||||
{ value: 'c', label: 'Land/Region (c)' },
|
||||
];
|
||||
|
||||
export default function EditorModal({ template, onSave, onClose }) {
|
||||
const isEditing = !!template;
|
||||
const toast = useToast();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [isAssignable, setIsAssignable] = useState(false);
|
||||
const [allowsFileUpload, setAllowsFileUpload] = useState(false);
|
||||
const [adCreate, setAdCreate] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pages, setPages] = useState({ 1: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState(null);
|
||||
const dragItemRef = useRef(null);
|
||||
// Punkt 8: Vorschau-Modus
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
// Punkt 16: Dirty-Check
|
||||
const [dirtyConfirm, setDirtyConfirm] = useState(false);
|
||||
const isDirty = () => {
|
||||
if (!template) return name.trim() !== '' || Object.values(pages).some(p => p.length > 0);
|
||||
return name !== (template.name || '') ||
|
||||
isAssignable !== !!template.is_assignable || allowsFileUpload !== !!template.allows_file_upload ||
|
||||
adCreate !== !!template.ad_create;
|
||||
};
|
||||
const handleClose = () => {
|
||||
if (isDirty()) {
|
||||
setDirtyConfirm(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Esc to close (with dirty check)
|
||||
useEffect(() => {
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') handleClose(); };
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
setName(template.name || '');
|
||||
setIsAssignable(!!template.is_assignable);
|
||||
setAllowsFileUpload(!!template.allows_file_upload);
|
||||
setAdCreate(!!template.ad_create);
|
||||
const parsed = {};
|
||||
(template.steps || []).forEach((s) => {
|
||||
const page = s.page_num || 1;
|
||||
if (!parsed[page]) parsed[page] = [];
|
||||
parsed[page].push({
|
||||
label: s.label,
|
||||
type: s.type,
|
||||
email_domain: s.email_domain || '',
|
||||
email_source_fields: s.email_source_fields || '',
|
||||
dropdown_options: s.dropdown_options || '',
|
||||
ad_field: s.ad_field || '',
|
||||
ad_prefix: s.ad_prefix || '',
|
||||
hidden: !!s.hidden,
|
||||
});
|
||||
});
|
||||
if (!parsed[1]) parsed[1] = [];
|
||||
setPages(parsed);
|
||||
setCurrentPage(Math.min(...Object.keys(parsed).map(Number)));
|
||||
}
|
||||
}, [template]);
|
||||
|
||||
const addPage = () => {
|
||||
const maxPage = Math.max(...Object.keys(pages).map(Number));
|
||||
const newPage = maxPage + 1;
|
||||
setPages((prev) => ({ ...prev, [newPage]: [] }));
|
||||
setCurrentPage(newPage);
|
||||
};
|
||||
|
||||
const removePage = (pageNum) => {
|
||||
if (Object.keys(pages).length <= 1) return; // Can't remove last page
|
||||
setPages((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[pageNum];
|
||||
return updated;
|
||||
});
|
||||
if (currentPage === pageNum) {
|
||||
const remaining = Object.keys(pages).map(Number).filter((p) => p !== pageNum);
|
||||
setCurrentPage(Math.min(...remaining));
|
||||
}
|
||||
};
|
||||
|
||||
const addStep = useCallback(() => {
|
||||
if ((pages[currentPage] || []).length >= MAX_STEPS_PER_PAGE) {
|
||||
toast.warning(`Maximal ${MAX_STEPS_PER_PAGE} Aufgaben pro Seite erlaubt!`);
|
||||
return;
|
||||
}
|
||||
setPages((prev) => ({
|
||||
...prev,
|
||||
[currentPage]: [...(prev[currentPage] || []), { label: '', type: 'text_input', email_domain: '', email_source_fields: '', dropdown_options: '', ad_field: '', ad_prefix: '', hidden: false }],
|
||||
}));
|
||||
}, [currentPage, pages]);
|
||||
|
||||
const updateStep = (idx, field, value) => {
|
||||
setPages((prev) => {
|
||||
const updated = [...(prev[currentPage] || [])];
|
||||
updated[idx] = { ...updated[idx], [field]: value };
|
||||
if (field === 'type' && value !== 'email') {
|
||||
updated[idx].email_domain = '';
|
||||
if (updated[idx].ad_field !== 'displayName') {
|
||||
updated[idx].email_source_fields = '';
|
||||
}
|
||||
}
|
||||
if (field === 'type' && value !== 'dropdown' && updated[idx].ad_field !== 'displayName') {
|
||||
updated[idx].dropdown_options = '';
|
||||
}
|
||||
if (field === 'type' && value === 'ad_password') {
|
||||
updated[idx].ad_field = 'password';
|
||||
}
|
||||
if (field === 'type' && value !== 'ad_password' && updated[idx].ad_field === 'password') {
|
||||
updated[idx].ad_field = '';
|
||||
}
|
||||
// When changing ad_field, preserve relevant fields
|
||||
if (field === 'ad_field') {
|
||||
if (value !== 'displayName' && value !== 'email' && value !== 'sAMAccountName') {
|
||||
updated[idx].email_source_fields = '';
|
||||
}
|
||||
if (value !== 'displayName' && value !== 'dropdown' && value !== 'password') {
|
||||
updated[idx].dropdown_options = '';
|
||||
}
|
||||
}
|
||||
return { ...prev, [currentPage]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const removeStep = (idx) => {
|
||||
setPages((prev) => ({
|
||||
...prev,
|
||||
[currentPage]: (prev[currentPage] || []).filter((_, i) => i !== idx),
|
||||
}));
|
||||
};
|
||||
|
||||
// Drag & Drop handlers
|
||||
const [isOverInput, setIsOverInput] = useState(false);
|
||||
|
||||
const handleDragStart = (idx) => {
|
||||
if (isOverInput) return;
|
||||
setDragIdx(idx);
|
||||
dragItemRef.current = idx;
|
||||
};
|
||||
|
||||
const handleDragOver = (e, idx) => {
|
||||
e.preventDefault();
|
||||
setDragOverIdx(idx);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (dragIdx !== null && dragOverIdx !== null && dragIdx !== dragOverIdx) {
|
||||
setPages((prev) => {
|
||||
const steps = [...(prev[currentPage] || [])];
|
||||
const [moved] = steps.splice(dragIdx, 1);
|
||||
steps.splice(dragOverIdx, 0, moved);
|
||||
return { ...prev, [currentPage]: steps };
|
||||
});
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
dragItemRef.current = null;
|
||||
};
|
||||
|
||||
const handleInputHover = (hovering) => {
|
||||
setIsOverInput(hovering);
|
||||
};
|
||||
|
||||
const getAllTextSteps = () => {
|
||||
const steps = [];
|
||||
Object.entries(pages).forEach(([pageNum, pageSteps]) => {
|
||||
pageSteps.forEach((step, idx) => {
|
||||
if (step.type === 'text_input' && step.label.trim()) {
|
||||
steps.push({ pageNum: parseInt(pageNum), idx, label: step.label.trim() });
|
||||
}
|
||||
});
|
||||
});
|
||||
return steps;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.warning('Bitte einen Vorlagen-Namen eingeben!');
|
||||
return;
|
||||
}
|
||||
for (const [pageNum, steps] of Object.entries(pages)) {
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (!steps[i].label.trim()) {
|
||||
toast.warning(`Bitte Label für Aufgabe ${i + 1} auf Seite ${pageNum} eingeben!`);
|
||||
return;
|
||||
}
|
||||
if (steps[i].type === 'email' && !steps[i].email_domain.trim()) {
|
||||
toast.warning(`Bitte E-Mail-Domain für "${steps[i].label}" auf Seite ${pageNum} eingeben!`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flatSteps = [];
|
||||
Object.entries(pages).forEach(([pageNum, steps]) => {
|
||||
steps.forEach((step) => {
|
||||
const stepData = {
|
||||
page_num: parseInt(pageNum),
|
||||
label: step.label,
|
||||
type: step.type,
|
||||
step_order: flatSteps.length + 1,
|
||||
};
|
||||
if (step.type === 'email') {
|
||||
stepData.email_domain = step.email_domain;
|
||||
stepData.email_source_fields = step.email_source_fields;
|
||||
}
|
||||
if (step.type === 'dropdown') {
|
||||
stepData.dropdown_options = step.dropdown_options;
|
||||
}
|
||||
if (step.ad_field === 'displayName') {
|
||||
stepData.email_source_fields = step.email_source_fields;
|
||||
stepData.dropdown_options = step.dropdown_options;
|
||||
}
|
||||
if (step.ad_field === 'sAMAccountName') {
|
||||
stepData.email_source_fields = step.email_source_fields;
|
||||
}
|
||||
if (step.ad_field === 'password') {
|
||||
stepData.dropdown_options = step.dropdown_options;
|
||||
}
|
||||
if (step.ad_field) {
|
||||
stepData.ad_field = step.ad_field;
|
||||
if (step.ad_prefix) stepData.ad_prefix = step.ad_prefix;
|
||||
}
|
||||
if (step.hidden) {
|
||||
stepData.hidden = true;
|
||||
}
|
||||
flatSteps.push(stepData);
|
||||
});
|
||||
});
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ name: name.trim(), is_assignable: isAssignable, allows_file_upload: allowsFileUpload, ad_create: adCreate, steps: flatSteps });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentSteps = pages[currentPage] || [];
|
||||
const pageNumbers = Object.keys(pages).map(Number).sort((a, b) => a - b);
|
||||
const allTextSteps = getAllTextSteps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && handleClose()}>
|
||||
<div className="modal-box max-w-3xl w-11/12 max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="font-bold text-lg mb-4">
|
||||
{isEditing ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}
|
||||
</h3>
|
||||
|
||||
{/* Template Name */}
|
||||
<div className="form-control mb-3">
|
||||
<label className="label">
|
||||
<span className="label-text font-medium">Vorlagen-Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. IT-Onboarding-Checkliste"
|
||||
className="input input-bordered w-full"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Is Assignable Toggle */}
|
||||
<div className="form-control mb-2">
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={isAssignable}
|
||||
onChange={(e) => setIsAssignable(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Als Aufgabe zuweisbar</span>
|
||||
<p className="text-xs opacity-60">User können diese Vorlage ausfüllen und als Aufgabe absenden</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* File Upload Toggle */}
|
||||
<div className="form-control mb-4">
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary"
|
||||
checked={allowsFileUpload}
|
||||
onChange={(e) => setAllowsFileUpload(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">Datei-Anhang erlauben</span>
|
||||
<p className="text-xs opacity-60">Mitarbeiter können Dateien zur Aufgabe hochladen</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* AD User Creation Toggle */}
|
||||
<div className="form-control mb-4">
|
||||
<label className="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-secondary"
|
||||
checked={adCreate}
|
||||
onChange={(e) => setAdCreate(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<span className="label-text font-medium">AD-Benutzer anlegen</span>
|
||||
<p className="text-xs opacity-60">Ermöglicht das direkte Anlegen eines Benutzers im Active Directory aus dieser Vorlage</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Page Tabs */}
|
||||
<div className="flex flex-wrap gap-2 mb-4 items-center">
|
||||
{pageNumbers.map((pageNum) => {
|
||||
const isActive = currentPage === pageNum;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
type="button"
|
||||
className={`flex items-center rounded-full overflow-hidden border transition-colors ${
|
||||
isActive ? 'bg-primary border-primary' : 'bg-base-100 border-base-300 hover:bg-base-200'
|
||||
}`}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
>
|
||||
<span className={`px-3 py-1.5 text-sm font-medium flex items-center gap-1.5 ${
|
||||
isActive ? 'text-primary-content' : 'text-base-content'
|
||||
}`}>
|
||||
Seite {pageNum}
|
||||
<span className={`badge badge-sm ${isActive ? 'badge-outline badge-primary-content' : 'badge-neutral'}`}>
|
||||
{(pages[pageNum] || []).length}
|
||||
</span>
|
||||
</span>
|
||||
{Object.keys(pages).length > 1 && (
|
||||
<span
|
||||
className={`px-2 py-1.5 h-full flex items-center justify-center transition-colors ${
|
||||
isActive
|
||||
? 'text-primary-content/70 hover:text-primary-content hover:bg-primary-focus'
|
||||
: 'text-base-content/60 hover:text-error hover:bg-error/10'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removePage(pageNum);
|
||||
}}
|
||||
title="Seite entfernen"
|
||||
role="button"
|
||||
aria-label="Seite entfernen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button className="btn btn-outline btn-sm rounded-full" onClick={addPage}>
|
||||
+ Seite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Steps for current page */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="font-semibold">Aufgaben (Seite {currentPage})</span>
|
||||
<span className="text-sm opacity-70">
|
||||
{currentSteps.length} / {MAX_STEPS_PER_PAGE}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<progress
|
||||
className="progress progress-primary w-full mb-3"
|
||||
value={currentSteps.length}
|
||||
max={MAX_STEPS_PER_PAGE}
|
||||
></progress>
|
||||
|
||||
{currentSteps.length === 0 && (
|
||||
<div className="text-center py-6 opacity-50">
|
||||
Noch keine Aufgaben auf Seite {currentPage}.<br />
|
||||
Klicke auf "+ Aufgabe hinzufügen" oder drücke Enter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentSteps.map((step, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
draggable={!isOverInput}
|
||||
onDragStart={() => handleDragStart(idx)}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragLeave={() => setDragOverIdx(null)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-base-200 p-3 rounded-lg mb-3 ${!isOverInput ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'} transition-all ${
|
||||
dragIdx === idx ? 'opacity-50' : ''
|
||||
} ${
|
||||
dragOverIdx === idx && dragIdx !== idx ? 'border-t-2 border-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="font-mono text-sm opacity-50 w-6 text-center select-none">{idx + 1}</span>
|
||||
{/* Drag handle */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 opacity-30 hover:opacity-70 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 8h16M4 16h16" /></svg>
|
||||
<select
|
||||
className="select select-bordered select-sm w-36"
|
||||
value={step.type}
|
||||
onChange={(e) => updateStep(idx, 'type', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
{STEP_TYPES.map((st) => (
|
||||
<option key={st.value} value={st.value}>{st.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Aufgaben-Label eingeben..."
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.label}
|
||||
onChange={(e) => updateStep(idx, 'label', e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addStep();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs btn-circle"
|
||||
onClick={() => updateStep(idx, 'hidden', !step.hidden)}
|
||||
title={step.hidden ? 'Konfiguration anzeigen (für User sichtbar)' : 'Konfiguration verstecken (für User unsichtbar)'}
|
||||
>
|
||||
{step.hidden ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" /></svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-xs btn-circle text-error"
|
||||
onClick={() => removeStep(idx)}
|
||||
title="Aufgabe entfernen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Email configuration */}
|
||||
{step.type === 'email' && (
|
||||
<div className="mt-3 ml-8 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Domain:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. @amt-leezen.de"
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.email_domain || ''}
|
||||
onChange={(e) => updateStep(idx, 'email_domain', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0 pt-0.5">Quellfelder:</label>
|
||||
<div className="flex-1 flex flex-col gap-1">
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const second = fields[1] || '';
|
||||
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">-- Feld 1 --</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`f1-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const first = fields[0] || '';
|
||||
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">-- Feld 2 (optional) --</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`f2-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
{allTextSteps.filter((s) => !(s.pageNum === currentPage && s.idx === idx)).length === 0 && (
|
||||
<p className="text-xs opacity-50">Keine Text-Felder vorhanden. Füge zuerst Text-Felder hinzu.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{step.email_domain && step.email_source_fields && (
|
||||
<p className="text-xs opacity-60 ml-8">
|
||||
Vorschau: {(step.email_source_fields || '').split('|').map(f => f.trim().toLowerCase().replace(/\s+/g, '.')).join('.')}{step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown configuration */}
|
||||
{step.type === 'dropdown' && (
|
||||
<div className="mt-3 ml-8 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Optionen:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Option 1 | Option 2 | Option 3 (max. 5)"
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.dropdown_options || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const options = val.split('|').map(o => o.trim()).filter(Boolean);
|
||||
if (options.length <= 5) {
|
||||
updateStep(idx, 'dropdown_options', val);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
</div>
|
||||
{step.dropdown_options && (
|
||||
<div className="ml-8 flex flex-wrap gap-1">
|
||||
{step.dropdown_options.split('|').map((o, i) => o.trim()).filter(Boolean).map((opt, i) => (
|
||||
<span key={i} className="badge badge-outline badge-sm">{opt}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AD field mapping - shown when ad_create is enabled */}
|
||||
{adCreate && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">AD-Feld:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
value={step.ad_field || ''}
|
||||
onChange={(e) => updateStep(idx, 'ad_field', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
{AD_FIELD_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AD prefix field - shown for address fields */}
|
||||
{adCreate && ['streetAddress', 'postOfficeBox', 'l', 'st', 'postalCode', 'c'].includes(step.ad_field) && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Präfix:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Fester Präfix (z.B. DE für Land)"
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.ad_prefix || ''}
|
||||
onChange={(e) => updateStep(idx, 'ad_prefix', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ad_password input field */}
|
||||
{adCreate && step.ad_field === 'password' && (
|
||||
<div className="mt-2 ml-8 flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Passwort:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Standardpasswort für neue AD-Benutzer"
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.dropdown_options || ''}
|
||||
onChange={(e) => updateStep(idx, 'dropdown_options', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ad_password info */}
|
||||
{adCreate && step.ad_field === 'password' && (
|
||||
<div className="mt-1 ml-8">
|
||||
<p className="text-xs text-secondary">Wird automatisch als Initialpasswort für den neuen AD-Benutzer verwendet.{step.dropdown_options ? ` Vergebenes Passwort: ${step.dropdown_options}` : ' Standard: InitialPass1!'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* displayName configuration */}
|
||||
{adCreate && step.ad_field === 'displayName' && (
|
||||
<div className="mt-3 ml-8 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 1:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const second = fields[1] || '';
|
||||
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">— Quellfeld wählen —</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`dn1-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 2:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const first = fields[0] || '';
|
||||
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">— Quellfeld wählen (optional) —</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`dn2-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Suffix:</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Amt Leezen"
|
||||
className="input input-bordered input-sm flex-1"
|
||||
value={step.dropdown_options || ''}
|
||||
onChange={(e) => updateStep(idx, 'dropdown_options', e.target.value)}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
/>
|
||||
</div>
|
||||
{step.email_source_fields && (
|
||||
<p className="text-xs opacity-60 ml-8">
|
||||
Vorschau: {(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean).join(', ')}{step.dropdown_options ? (step.dropdown_options.trim().startsWith('(') ? ` ${step.dropdown_options.trim()}` : ` (${step.dropdown_options.trim()})`) : ''}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-secondary ml-8">Wird als Anzeigename im AD gesetzt (z.B. "Mustermann, Thomas (Amt Leezen)"). Für User nicht sichtbar.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sAMAccountName configuration */}
|
||||
{/* sAMAccountName configuration */}
|
||||
{adCreate && step.ad_field === 'sAMAccountName' && (
|
||||
<div className="mt-3 ml-8 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 1:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[0] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const second = fields[1] || '';
|
||||
updateStep(idx, 'email_source_fields', [e.target.value, second].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">— Quellfeld wählen —</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`sam1-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium opacity-70 w-24 shrink-0">Quellfeld 2:</label>
|
||||
<select
|
||||
className="select select-bordered select-sm flex-1"
|
||||
value={(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean)[1] || ''}
|
||||
onChange={(e) => {
|
||||
const fields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const first = fields[0] || '';
|
||||
updateStep(idx, 'email_source_fields', [first, e.target.value].filter(Boolean).join('|'));
|
||||
}}
|
||||
onMouseEnter={() => handleInputHover(true)}
|
||||
onMouseLeave={() => handleInputHover(false)}
|
||||
>
|
||||
<option value="">— Quellfeld wählen (optional) —</option>
|
||||
{allTextSteps
|
||||
.filter((s) => !(s.pageNum === currentPage && s.idx === idx))
|
||||
.map((s, i) => (
|
||||
<option key={`sam2-${s.pageNum}-${s.idx}`} value={s.label}>
|
||||
{i + 1}: {s.label}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
{step.email_source_fields && (
|
||||
<p className="text-xs opacity-60 ml-8">
|
||||
Vorschau UPN: {(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean).join('.').toLowerCase().replace(/\s+/g, '.')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-secondary ml-8">UPN-Format: Vorname.Nachname (z.B. thomas.mustermann). Prä-Windows-2000-Name bleibt NachnameV (z.B. MustermannT).</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentSteps.length < MAX_STEPS_PER_PAGE && (
|
||||
<button className="btn btn-outline btn-sm mt-2" onClick={addStep}>
|
||||
+ Aufgabe hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</button>
|
||||
{/* Punkt 8: Vorschau-Button */}
|
||||
{name.trim() && Object.values(pages).some(p => p.length > 0) && (
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setShowPreview(true)}
|
||||
disabled={saving}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
|
||||
Vorschau
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-primary btn-sm ${saving ? 'loading' : ''}`}
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? <span className="loading loading-spinner loading-sm"></span> : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Punkt 8: Vorschau-Modal */}
|
||||
{showPreview && <EditorPreview name={name} pages={pages} onClose={() => setShowPreview(false)} />}
|
||||
|
||||
{/* Punkt 16: Dirty-Check ConfirmModal */}
|
||||
<ConfirmModal
|
||||
open={dirtyConfirm}
|
||||
title="Ungespeicherte Änderungen"
|
||||
message="Du hast Änderungen vorgenommen, die noch nicht gespeichert wurden. Möchtest du wirklich abbrechen?"
|
||||
confirmLabel="Abbrechen"
|
||||
cancelLabel="Weiter bearbeiten"
|
||||
onConfirm={() => { setDirtyConfirm(false); onClose(); }}
|
||||
onCancel={() => setDirtyConfirm(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/ErrorBoundary.jsx
Normal file
38
frontend/src/components/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="card bg-base-200 shadow-lg max-w-md w-full">
|
||||
<div className="card-body text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 text-error mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-bold mb-2">Etwas ist schiefgelaufen</h2>
|
||||
<p className="text-sm opacity-70 mb-4">Ein unerwarteter Fehler ist aufgetreten.</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => window.location.reload()}>
|
||||
Seite neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
432
frontend/src/components/FillModal.jsx
Normal file
432
frontend/src/components/FillModal.jsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast, ConfirmModal } from './Toast';
|
||||
import { apiFetch, FILE_BASE } from '../utils/api';
|
||||
|
||||
export default function FillModal({ template, onSubmit, onClose }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const toast = useToast();
|
||||
const pageNumbers = [...new Set(template.steps.map((s) => s.page_num))].sort((a, b) => a - b);
|
||||
const totalPages = pageNumbers.length;
|
||||
const allowsFileUpload = !!template.allows_file_upload;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(pageNumbers[0] || 1);
|
||||
const [values, setValues] = useState(() => {
|
||||
const initial = {};
|
||||
template.steps.forEach((s) => {
|
||||
if (s.type === 'checkbox') {
|
||||
initial[s.id] = false;
|
||||
} else {
|
||||
initial[s.id] = '';
|
||||
}
|
||||
});
|
||||
return initial;
|
||||
});
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [uploadedFileInfo, setUploadedFileInfo] = useState(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Punkt 16: Dirty-Check
|
||||
const [dirtyConfirm, setDirtyConfirm] = useState(false);
|
||||
const initialValuesRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const initial = {};
|
||||
template.steps.forEach((s) => {
|
||||
initial[s.id] = s.type === 'checkbox' ? false : '';
|
||||
});
|
||||
initialValuesRef.current = initial;
|
||||
}, [template]);
|
||||
|
||||
const isDirty = () => {
|
||||
if (!initialValuesRef.current) return false;
|
||||
return Object.keys(values).some(key => values[key] !== initialValuesRef.current[key]) || !!uploadedFile;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty()) {
|
||||
setDirtyConfirm(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Esc to close (with dirty check)
|
||||
useEffect(() => {
|
||||
const handleEsc = (e) => { if (e.key === 'Escape') handleClose(); };
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
const pageSteps = template.steps.filter((s) => s.page_num === currentPage);
|
||||
const currentIdx = pageNumbers.indexOf(currentPage);
|
||||
const isFirstPage = currentIdx === 0;
|
||||
const isLastPage = pageNumbers.length - 1 === currentIdx;
|
||||
|
||||
const handleChange = (stepId, value) => {
|
||||
setValues((prev) => ({ ...prev, [stepId]: value }));
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const res = await apiFetch('/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Fehler beim Hochladen der Datei.');
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setUploadedFile(data.url);
|
||||
setUploadedFileInfo(data);
|
||||
} catch (e) {
|
||||
toast.error('Netzwerkfehler beim Hochladen der Datei.');
|
||||
}
|
||||
};
|
||||
|
||||
const generateEmail = (step) => {
|
||||
if (step.type !== 'email' || !step.email_domain) return '';
|
||||
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
if (sourceFields.length === 0) return '';
|
||||
const parts = sourceFields.map((fieldLabel) => {
|
||||
const sourceStep = template.steps.find((s) => s.label === fieldLabel);
|
||||
if (!sourceStep || !values[sourceStep.id]) return '';
|
||||
return values[sourceStep.id].trim().toLowerCase().replace(/\s+/g, '.');
|
||||
}).filter(Boolean);
|
||||
if (parts.length === 0) return '';
|
||||
const domain = step.email_domain.startsWith('@') ? step.email_domain : '@' + step.email_domain;
|
||||
return parts.join('.') + domain;
|
||||
};
|
||||
|
||||
const goNext = () => {
|
||||
if (!isLastPage) setCurrentPage(pageNumbers[currentIdx + 1]);
|
||||
};
|
||||
|
||||
const goPrev = () => {
|
||||
if (!isFirstPage) setCurrentPage(pageNumbers[currentIdx - 1]);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const taskValues = template.steps.map((s) => {
|
||||
let value = '';
|
||||
if (s.type === 'checkbox') {
|
||||
// Checkbox: store boolean state as value string for display, plus is_checked flag
|
||||
value = values[s.id] ? 'true' : 'false';
|
||||
} else if (s.type === 'email') {
|
||||
value = generateEmail(s);
|
||||
} else if (s.ad_field === 'password') {
|
||||
// password: use the password from the template, or fallback to InitialPass1!
|
||||
value = s.dropdown_options || 'InitialPass1!';
|
||||
} else if (s.ad_field === 'displayName') {
|
||||
// displayName: auto-generate from source fields + suffix
|
||||
// Format: "Quellfeld1, Quellfeld2 (Suffix)" e.g. "Mustermann, Thomas (Amt Leezen)"
|
||||
const sourceFields = (s.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const sourceValues = sourceFields.map(fieldLabel => {
|
||||
const sourceStep = template.steps.find(st => st.label === fieldLabel);
|
||||
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
|
||||
}).filter(Boolean);
|
||||
const suffix = (s.dropdown_options || '').trim();
|
||||
if (sourceValues.length > 0) {
|
||||
if (suffix) {
|
||||
// If suffix already starts with '(', use it as-is; otherwise wrap in parentheses
|
||||
value = sourceValues.join(', ') + (suffix.startsWith('(') ? ` ${suffix}` : ` (${suffix})`);
|
||||
} else {
|
||||
value = sourceValues.join(', ');
|
||||
}
|
||||
} else {
|
||||
value = '';
|
||||
}
|
||||
} else if (s.ad_field === 'sAMAccountName') {
|
||||
// Auto-generate username from source fields if configured, otherwise from givenName/sn
|
||||
// UPN format: Vorname.Nachname (e.g. thomas.mustermann)
|
||||
// Pre-Windows 2000 format: NachnameV (e.g. MustermannT) - always auto-generated
|
||||
const sourceFields = (s.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
if (sourceFields.length > 0) {
|
||||
const sourceValues = sourceFields.map(fieldLabel => {
|
||||
const sourceStep = template.steps.find(st => st.label === fieldLabel);
|
||||
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
|
||||
}).filter(Boolean);
|
||||
if (sourceValues.length > 0) {
|
||||
value = sourceValues.join('.')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '');
|
||||
} else {
|
||||
value = '';
|
||||
}
|
||||
} else {
|
||||
// Fallback: Nachname + FirstInitial (e.g. MustermannT)
|
||||
const vornameStep = template.steps.find(st => st.ad_field === 'givenName');
|
||||
const nachnameStep = template.steps.find(st => st.ad_field === 'sn');
|
||||
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
|
||||
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
|
||||
if (vorname && nachname) {
|
||||
value = (nachname + vorname.charAt(0))
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
} else {
|
||||
value = values[s.id] || '';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value = values[s.id] || '';
|
||||
}
|
||||
return {
|
||||
step_id: s.id,
|
||||
value,
|
||||
is_checked: s.type === 'checkbox' ? (values[s.id] ? 1 : 0) : 0,
|
||||
file_path: null,
|
||||
};
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
template_id: template.id,
|
||||
title: template.name,
|
||||
values: taskValues,
|
||||
file_path: (allowsFileUpload && uploadedFile) ? uploadedFile : null,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && handleClose()}>
|
||||
<div className="modal-box max-w-2xl w-11/12 max-h-[85vh]">
|
||||
<h3 className="font-bold text-lg mb-1">{template.name}</h3>
|
||||
{template.description && (
|
||||
<p className="text-sm opacity-70 mb-4">{template.description}</p>
|
||||
)}
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{pageNumbers.map((page) => {
|
||||
const count = template.steps.filter((s) => s.page_num === page).length;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
className={`btn btn-sm ${currentPage === page ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
Seite {page}
|
||||
<span className="badge badge-sm ml-1">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps - scrollable area */}
|
||||
<div className="overflow-y-auto max-h-[50vh] flex flex-col gap-3 mb-4 pr-1">
|
||||
{pageSteps.length === 0 && (
|
||||
<div className="text-center py-4 opacity-50">
|
||||
Keine Aufgaben auf Seite {currentPage}.
|
||||
</div>
|
||||
)}
|
||||
{pageSteps.map((step) => {
|
||||
// Hide hidden steps from non-admins
|
||||
if (!!step.hidden && !isAdmin) return null;
|
||||
return (
|
||||
<div key={step.id} className="bg-base-200 p-3 rounded-lg">
|
||||
<label className="font-medium text-sm mb-1 block">
|
||||
{step.label}
|
||||
</label>
|
||||
{!step.hidden && step.type === 'text_input' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={step.label}
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={values[step.id] || ''}
|
||||
onChange={(e) => handleChange(step.id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{!step.hidden && step.type === 'checkbox' && (
|
||||
<label className="label cursor-pointer justify-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-primary checkbox-sm"
|
||||
checked={!!values[step.id]}
|
||||
onChange={(e) => handleChange(step.id, e.target.checked)}
|
||||
/>
|
||||
<span className="label-text text-sm">Ja</span>
|
||||
</label>
|
||||
)}
|
||||
{!step.hidden && step.type === 'email' && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full bg-base-300"
|
||||
value={generateEmail(step)}
|
||||
readOnly
|
||||
placeholder={step.email_source_fields ? 'Wird automatisch generiert...' : 'Quellfeld wählen'}
|
||||
/>
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Wird automatisch aus "{step.email_source_fields}" und "{step.email_domain}" generiert
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!step.hidden && step.type === 'dropdown' && (
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={values[step.id] || ''}
|
||||
onChange={(e) => handleChange(step.id, e.target.value)}
|
||||
>
|
||||
<option value="">-- Bitte wählen --</option>
|
||||
{(step.dropdown_options || '').split('|').map(o => o.trim()).filter(Boolean).map((opt, i) => (
|
||||
<option key={i} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* Admin-only: show auto-generated AD fields as read-only */}
|
||||
{!!step.hidden && step.ad_field === 'password' && isAdmin && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full bg-base-300"
|
||||
value={step.dropdown_options || 'InitialPass1!'}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-secondary mt-1">AD-Standardpasswort (automatisch)</p>
|
||||
</div>
|
||||
)}
|
||||
{!!step.hidden && step.ad_field === 'displayName' && isAdmin && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full bg-base-300"
|
||||
value={(() => {
|
||||
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
const sourceValues = sourceFields.map(fieldLabel => {
|
||||
const sourceStep = template.steps.find(st => st.label === fieldLabel);
|
||||
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
|
||||
}).filter(Boolean);
|
||||
const suffix = (step.dropdown_options || '').trim();
|
||||
if (sourceValues.length === 0) return '';
|
||||
if (suffix) {
|
||||
return sourceValues.join(', ') + (suffix.startsWith('(') ? ` ${suffix}` : ` (${suffix})`);
|
||||
}
|
||||
return sourceValues.join(', ');
|
||||
})()}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-secondary mt-1">AD-Anzeigename (automatisch{step.dropdown_options ? `, Suffix: ${step.dropdown_options}` : ''})</p>
|
||||
</div>
|
||||
)}
|
||||
{!!step.hidden && step.ad_field === 'sAMAccountName' && isAdmin && (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full bg-base-300"
|
||||
value={(() => {
|
||||
const sourceFields = (step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean);
|
||||
if (sourceFields.length > 0) {
|
||||
const sourceValues = sourceFields.map(fieldLabel => {
|
||||
const sourceStep = template.steps.find(st => st.label === fieldLabel);
|
||||
return sourceStep ? (values[sourceStep.id] || '').trim() : '';
|
||||
}).filter(Boolean);
|
||||
if (sourceValues.length > 0) {
|
||||
return sourceValues.join('.')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||
.replace(/Ä/g, 'ae').replace(/Ö/g, 'oe').replace(/Ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/[^a-zA-Z0-9.]/g, '');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
// Fallback: Nachname + FirstInitial
|
||||
const vornameStep = template.steps.find(st => st.ad_field === 'givenName');
|
||||
const nachnameStep = template.steps.find(st => st.ad_field === 'sn');
|
||||
const vorname = vornameStep ? (values[vornameStep.id] || '').trim() : '';
|
||||
const nachname = nachnameStep ? (values[nachnameStep.id] || '').trim() : '';
|
||||
if (vorname && nachname) {
|
||||
return (nachname + vorname.charAt(0))
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue')
|
||||
.replace(/Ä/g, 'Ae').replace(/Ö/g, 'Oe').replace(/Ü/g, 'Ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-zA-Z0-9]/g, '');
|
||||
}
|
||||
return '';
|
||||
})()}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-secondary mt-1">AD-Anmeldename (automatisch generiert)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* File Upload Section - shown on last page if enabled */}
|
||||
{allowsFileUpload && isLastPage && (
|
||||
<div className="bg-base-200 p-3 rounded-lg border-2 border-dashed border-base-300">
|
||||
<label className="font-medium text-sm mb-1 block">
|
||||
Datei anhängen
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
className="file-input file-input-bordered file-input-sm w-full"
|
||||
onChange={(e) => handleFileUpload(e.target.files[0])}
|
||||
/>
|
||||
{uploadedFileInfo && (
|
||||
<p className="text-xs text-success mt-1">
|
||||
{uploadedFileInfo.originalname} hochgeladen
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost btn-sm" onClick={handleClose} disabled={submitting}>
|
||||
Abbrechen
|
||||
</button>
|
||||
{!isFirstPage && (
|
||||
<button className="btn btn-outline btn-sm" onClick={goPrev}>
|
||||
Zurück
|
||||
</button>
|
||||
)}
|
||||
{!isLastPage && (
|
||||
<button className="btn btn-secondary btn-sm" onClick={goNext}>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
{isLastPage && (
|
||||
<button
|
||||
className={`btn btn-primary btn-sm ${submitting ? 'loading' : ''}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <span className="loading loading-spinner loading-sm"></span> : (template.is_assignable ? 'Abschicken' : 'Fertig')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Punkt 16: Dirty-Check ConfirmModal */}
|
||||
<ConfirmModal
|
||||
open={dirtyConfirm}
|
||||
title="Ungespeicherte Änderungen"
|
||||
message="Du hast Änderungen vorgenommen, die noch nicht gespeichert wurden. Möchtest du wirklich abbrechen?"
|
||||
confirmLabel="Abbrechen"
|
||||
cancelLabel="Weiter bearbeiten"
|
||||
onConfirm={() => { setDirtyConfirm(false); onClose(); }}
|
||||
onCancel={() => setDirtyConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/Sidebar.jsx
Normal file
107
frontend/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// Punkt 5: SVG Icons für jeden Tab
|
||||
const tabIcons = {
|
||||
dashboard: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zm10 0a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" /></svg>
|
||||
),
|
||||
templates: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
),
|
||||
tasks: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /></svg>
|
||||
),
|
||||
users: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
|
||||
),
|
||||
auditlog: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function Sidebar({ activeTab, onTabChange }) {
|
||||
const { user, logout } = useAuth();
|
||||
// Punkt 19: Collapsible Sidebar State
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard', label: 'Vorlagen' },
|
||||
{ id: 'templates', label: 'Vorlageneditor' },
|
||||
...(user?.role === 'admin' ? [{ id: 'tasks', label: 'Aufgaben' }] : []),
|
||||
...(user?.role === 'admin' ? [{ id: 'users', label: 'Nutzerverwaltung' }] : []),
|
||||
...(user?.role === 'admin' ? [{ id: 'auditlog', label: 'Audit-Log' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="drawer-side z-40">
|
||||
<label htmlFor="sidebar-drawer" className="drawer-overlay"></label>
|
||||
<aside className={`${collapsed ? 'w-16' : 'w-64'} min-h-full bg-base-200 flex flex-col transition-all duration-300`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-base-300 flex items-center justify-between">
|
||||
{!collapsed && (
|
||||
<h1 className="text-xl font-bold truncate">Workflow Portal</h1>
|
||||
)}
|
||||
{/* Punkt 19: Collapse Toggle - hidden on mobile (drawer handles it) */}
|
||||
<button
|
||||
className="btn btn-ghost btn-xs btn-square hidden lg:flex"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? 'Sidebar aufklappen' : 'Sidebar einklappen'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`h-4 w-4 transition-transform ${collapsed ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<ul className="menu p-2 gap-1 flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<li key={tab.id}>
|
||||
<button
|
||||
className={`flex items-center gap-3 ${activeTab === tab.id ? 'active' : ''} ${collapsed ? 'justify-center px-0' : ''}`}
|
||||
onClick={() => {
|
||||
onTabChange(tab.id);
|
||||
// Close drawer on mobile after selection
|
||||
const drawer = document.getElementById('sidebar-drawer');
|
||||
if (drawer) drawer.checked = false;
|
||||
}}
|
||||
title={collapsed ? tab.label : undefined}
|
||||
>
|
||||
{tabIcons[tab.id]}
|
||||
{!collapsed && <span>{tab.label}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* User Info & Logout */}
|
||||
<div className={`p-4 border-t border-base-300 ${collapsed ? 'flex flex-col items-center gap-2' : ''}`}>
|
||||
<div className={`flex items-center gap-3 ${collapsed ? '' : 'mb-3'}`}>
|
||||
<div className="avatar placeholder">
|
||||
<div className="bg-primary text-primary-content rounded-full w-10">
|
||||
<span className="text-sm">{user?.name?.charAt(0)?.toUpperCase() || user?.email?.charAt(0)?.toUpperCase() || '?'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div>
|
||||
<div className="font-semibold text-sm">{user?.name || user?.email}</div>
|
||||
<div className="badge badge-sm badge-primary">{user?.role}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<button className="btn btn-outline btn-sm w-full" onClick={logout}>
|
||||
Abmelden
|
||||
</button>
|
||||
)}
|
||||
{collapsed && (
|
||||
<button className="btn btn-ghost btn-xs btn-square" onClick={logout} title="Abmelden">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1226
frontend/src/components/TaskModal.jsx
Normal file
1226
frontend/src/components/TaskModal.jsx
Normal file
File diff suppressed because it is too large
Load Diff
39
frontend/src/components/TemplateCard.jsx
Normal file
39
frontend/src/components/TemplateCard.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function TemplateCard({ template, onOpen, onEdit, onDelete }) {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const pageCount = [...new Set(template.steps?.map((s) => s.page_num) || [])].length;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-bold text-base truncate">{template.name}</h3>
|
||||
{pageCount > 1 && (
|
||||
<span className="badge badge-ghost badge-sm">{pageCount} Seiten</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs opacity-70 line-clamp-1 mt-1">
|
||||
{template.description || 'Keine Beschreibung'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onEdit(template)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-sm text-error" onClick={() => onDelete(template.id)} title="Löschen">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/Toast.jsx
Normal file
100
frontend/src/components/Toast.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useState, useEffect, useCallback, createContext, useContext } from 'react';
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }) {
|
||||
const [exiting, setExiting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setExiting(true);
|
||||
setTimeout(() => onRemove(toast.id), 300);
|
||||
}, toast.duration || 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [toast.id, toast.duration, onRemove]);
|
||||
|
||||
const typeClass = {
|
||||
success: 'alert-success',
|
||||
error: 'alert-error',
|
||||
warning: 'alert-warning',
|
||||
info: 'alert-info',
|
||||
}[toast.type] || 'alert-info';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`alert ${typeClass} shadow-lg transition-all duration-300 ${
|
||||
exiting ? 'opacity-0 translate-x-full' : 'opacity-100 translate-x-0'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{toast.type === 'success' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
)}
|
||||
{toast.type === 'error' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
)}
|
||||
{toast.type === 'warning' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
)}
|
||||
{toast.type === 'info' && (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
)}
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-xs" onClick={() => { setExiting(true); setTimeout(() => onRemove(toast.id), 300); }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setToasts((prev) => [...prev, { id, message, type, duration }]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const success = useCallback((msg, dur) => addToast(msg, 'success', dur), [addToast]);
|
||||
const error = useCallback((msg, dur) => addToast(msg, 'error', dur), [addToast]);
|
||||
const warning = useCallback((msg, dur) => addToast(msg, 'warning', dur), [addToast]);
|
||||
const info = useCallback((msg, dur) => addToast(msg, 'info', dur), [addToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast, success, error, warning, info }}>
|
||||
{children}
|
||||
{/* Punkt 14: Toast container responsive - avoids sidebar overlap on lg screens */}
|
||||
<div className="fixed top-4 right-4 lg:right-[5rem] z-[9999] flex flex-col gap-2 w-80">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/* Confirm Modal - replaces browser confirm() */
|
||||
export function ConfirmModal({ open, title, message, confirmLabel, cancelLabel, onConfirm, onCancel }) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal modal-open" onClick={(e) => e.target === e.currentTarget && onCancel()}>
|
||||
<div className="modal-box max-w-sm">
|
||||
<h3 className="font-bold text-lg">{title || 'Bestätigung'}</h3>
|
||||
<p className="py-4">{message}</p>
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost btn-sm" onClick={onCancel}>{cancelLabel || 'Abbrechen'}</button>
|
||||
<button className="btn btn-error btn-sm" onClick={onConfirm}>{confirmLabel || 'Löschen'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/context/AuthContext.jsx
Normal file
90
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { API_BASE, setCSRFToken, getCSRFToken } from '../utils/api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('workflow_user');
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Verify session on mount - P5: cookie-only auth (no token in localStorage)
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
|
||||
.then(res => res.ok ? res.json() : Promise.reject())
|
||||
.then(data => { setUser(data); localStorage.setItem('workflow_user', JSON.stringify(data)); })
|
||||
.catch(() => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('workflow_user');
|
||||
setCSRFToken(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const res = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
credentials: 'include', // P5: HttpOnly-Cookie only
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Anmeldung fehlgeschlagen');
|
||||
setUser(data);
|
||||
localStorage.setItem('workflow_user', JSON.stringify(data));
|
||||
// P4: Store CSRF token in memory (returned from login response)
|
||||
if (data.csrfToken) setCSRFToken(data.csrfToken);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (email, password, name = '') => {
|
||||
const res = await fetch(`${API_BASE}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name, role: 'user' }),
|
||||
credentials: 'include', // P5: HttpOnly-Cookie only
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Registrierung fehlgeschlagen');
|
||||
setUser(data);
|
||||
localStorage.setItem('workflow_user', JSON.stringify(data));
|
||||
// P4: Store CSRF token in memory (returned from register response)
|
||||
if (data.csrfToken) setCSRFToken(data.csrfToken);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
// Bug 2/4: Send CSRF token with logout request
|
||||
const csrf = getCSRFToken();
|
||||
await fetch(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'x-csrf-token': csrf } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
setUser(null);
|
||||
localStorage.removeItem('workflow_user');
|
||||
setCSRFToken(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
41
frontend/src/index.css
Normal file
41
frontend/src/index.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Square checkboxes instead of round */
|
||||
.checkbox {
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
.checkbox:checked {
|
||||
border-radius: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Disable all DaisyUI/Tailwind transitions on buttons and cards to eliminate blur/jank */
|
||||
.btn,
|
||||
.btn-ghost,
|
||||
.btn-primary,
|
||||
.btn-outline,
|
||||
.card {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
will-change: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Explicitly disable DaisyUI button-pop animation */
|
||||
@keyframes button-pop {
|
||||
0% { transform: none; }
|
||||
100% { transform: none; }
|
||||
}
|
||||
|
||||
/* Static skeleton placeholders (no animation) */
|
||||
.static-skeleton {
|
||||
background-color: oklch(var(--b3));
|
||||
border-radius: var(--rounded-box, 1rem);
|
||||
}
|
||||
|
||||
/* Remove hover transitions globally for interactive elements during page load */
|
||||
.no-transition {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
248
frontend/src/pages/AuditLogPage.jsx
Normal file
248
frontend/src/pages/AuditLogPage.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Punkt 17: Filter-States
|
||||
const [filterAction, setFilterAction] = useState('');
|
||||
const [filterUser, setFilterUser] = useState('');
|
||||
const [filterDateFrom, setFilterDateFrom] = useState('');
|
||||
const [filterDateTo, setFilterDateTo] = useState('');
|
||||
|
||||
const fetchAuditLog = useCallback(async (p = page) => {
|
||||
try {
|
||||
let url = `/audit-log?page=${p}&limit=25`;
|
||||
if (filterAction) url += `&action=${encodeURIComponent(filterAction)}`;
|
||||
if (filterUser) url += `&user=${encodeURIComponent(filterUser)}`;
|
||||
if (filterDateFrom) url += `&from=${encodeURIComponent(filterDateFrom)}`;
|
||||
if (filterDateTo) url += `&to=${encodeURIComponent(filterDateTo)}`;
|
||||
const res = await apiFetch(url);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.entries || []);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
setTotal(data.total || 0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden des Audit-Logs:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, filterAction, filterUser, filterDateFrom, filterDateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLog();
|
||||
}, [fetchAuditLog]);
|
||||
|
||||
// Reset page when filters change
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setLoading(true);
|
||||
fetchAuditLog(1);
|
||||
}, [filterAction, filterUser, filterDateFrom, filterDateTo]);
|
||||
|
||||
const goToPage = (p) => {
|
||||
setPage(p);
|
||||
setLoading(true);
|
||||
fetchAuditLog(p);
|
||||
};
|
||||
|
||||
const formatAction = (action) => {
|
||||
const map = {
|
||||
login: 'Anmeldung',
|
||||
logout: 'Abmeldung',
|
||||
register: 'Registrierung',
|
||||
// Dot format (new)
|
||||
'user.create': 'Nutzer erstellt',
|
||||
'user.update': 'Nutzer bearbeitet',
|
||||
'user.delete': 'Nutzer gelöscht',
|
||||
'template.create': 'Vorlage erstellt',
|
||||
'template.update': 'Vorlage bearbeitet',
|
||||
'template.delete': 'Vorlage gelöscht',
|
||||
'task.create': 'Aufgabe erstellt',
|
||||
'task.update': 'Aufgabe bearbeitet',
|
||||
'task.delete': 'Aufgabe gelöscht',
|
||||
'task.add-field': 'Feld hinzugefügt',
|
||||
'task.delete-field': 'Feld gelöscht',
|
||||
'ad.create-user': 'AD-Nutzer erstellt',
|
||||
'ad.create-user-failed': 'AD-Nutzer-Erstellung fehlgeschlagen',
|
||||
'ad.delete-user': 'AD-Nutzer gelöscht',
|
||||
// Underscore format (legacy/actual backend)
|
||||
create_user: 'Nutzer erstellt',
|
||||
update_user: 'Nutzer bearbeitet',
|
||||
delete_user: 'Nutzer gelöscht',
|
||||
create_template: 'Vorlage erstellt',
|
||||
update_template: 'Vorlage bearbeitet',
|
||||
delete_template: 'Vorlage gelöscht',
|
||||
create_task: 'Aufgabe erstellt',
|
||||
update_task: 'Aufgabe bearbeitet',
|
||||
delete_task: 'Aufgabe gelöscht',
|
||||
add_field: 'Feld hinzugefügt',
|
||||
delete_field: 'Feld gelöscht',
|
||||
};
|
||||
return map[action] || action;
|
||||
};
|
||||
|
||||
const formatEntityType = (type) => {
|
||||
const map = {
|
||||
user: 'Nutzer',
|
||||
template: 'Vorlage',
|
||||
task: 'Aufgabe',
|
||||
session: 'Sitzung',
|
||||
ad_user: 'AD-Nutzer',
|
||||
};
|
||||
return map[type] || type || '—';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="static-skeleton h-8 w-32"></div>
|
||||
<div className="static-skeleton h-4 w-24"></div>
|
||||
</div>
|
||||
<div className="static-skeleton h-16 w-full"></div>
|
||||
<div className="static-skeleton h-40 w-full"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Audit-Log</h2>
|
||||
<p className="text-sm opacity-60 mb-4">{total} Einträge</p>
|
||||
|
||||
{/* Punkt 17: Filter Controls */}
|
||||
<div className="flex flex-wrap items-end gap-3 mb-4 p-3 bg-base-200 rounded-lg">
|
||||
<div className="form-control">
|
||||
<label className="label py-1"><span className="label-text text-xs">Aktion</span></label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-44"
|
||||
value={filterAction}
|
||||
onChange={(e) => setFilterAction(e.target.value)}
|
||||
>
|
||||
<option value="">Alle Aktionen</option>
|
||||
<option value="login">Anmeldung</option>
|
||||
<option value="logout">Abmeldung</option>
|
||||
<option value="register">Registrierung</option>
|
||||
<option value="user.create">Nutzer erstellt</option>
|
||||
<option value="user.update">Nutzer bearbeitet</option>
|
||||
<option value="user.delete">Nutzer gelöscht</option>
|
||||
<option value="template.create">Vorlage erstellt</option>
|
||||
<option value="template.update">Vorlage bearbeitet</option>
|
||||
<option value="template.delete">Vorlage gelöscht</option>
|
||||
<option value="task.create">Aufgabe erstellt</option>
|
||||
<option value="task.update">Aufgabe bearbeitet</option>
|
||||
<option value="task.delete">Aufgabe gelöscht</option>
|
||||
<option value="ad.create-user">AD-Nutzer erstellt</option>
|
||||
<option value="ad.delete-user">AD-Nutzer gelöscht</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label py-1"><span className="label-text text-xs">Nutzer</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nutzer suchen..."
|
||||
className="input input-bordered input-sm w-40"
|
||||
value={filterUser}
|
||||
onChange={(e) => setFilterUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label py-1"><span className="label-text text-xs">Von</span></label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered input-sm w-36"
|
||||
value={filterDateFrom}
|
||||
onChange={(e) => setFilterDateFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label py-1"><span className="label-text text-xs">Bis</span></label>
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered input-sm w-36"
|
||||
value={filterDateTo}
|
||||
onChange={(e) => setFilterDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => { setFilterAction(''); setFilterUser(''); setFilterDateFrom(''); setFilterDateTo(''); }}
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="hero min-h-[30vh] bg-base-200 rounded-lg">
|
||||
<div className="hero-content text-center">
|
||||
<p className="text-lg opacity-70">Keine Audit-Einträge vorhanden.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Aktion</th>
|
||||
<th>Entität</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="hover">
|
||||
<td className="text-xs whitespace-nowrap">
|
||||
{new Date(entry.created_at).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="text-sm">{entry.user_name || 'System'}</td>
|
||||
<td>
|
||||
<span className="badge badge-sm badge-outline">{formatAction(entry.action)}</span>
|
||||
</td>
|
||||
<td className="text-sm">
|
||||
{formatEntityType(entry.entity_type)}
|
||||
{entry.entity_id ? ` #${entry.entity_id}` : ''}
|
||||
</td>
|
||||
<td className="text-xs opacity-70 max-w-xs truncate" title={entry.details}>
|
||||
{entry.details || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
>
|
||||
« Zurück
|
||||
</button>
|
||||
<span className="text-sm opacity-70">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
>
|
||||
Weiter »
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
frontend/src/pages/Dashboard.jsx
Normal file
259
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast } from '../components/Toast';
|
||||
import FillModal from '../components/FillModal';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
export default function Dashboard({ onNavigate }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [stats, setStats] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fillTemplate, setFillTemplate] = useState(null);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!isAdmin) return;
|
||||
try {
|
||||
const res = await apiFetch('/stats');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Statistiken:', e);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchTemplates, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTemplates, fetchStats]);
|
||||
|
||||
// Alle Vorlagen sind für jeden User sichtbar (nicht mehr nach is_assignable gefiltert)
|
||||
const topTemplates = stats?.topTemplates || [];
|
||||
const filtered = templates.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(t.description || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSubmitTask = async (payload) => {
|
||||
const res = await apiFetch('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, user_id: user.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Fehler beim Absenden');
|
||||
}
|
||||
setFillTemplate(null);
|
||||
toast.success('Aufgabe erfolgreich abgesendet!');
|
||||
fetchStats();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
<div className="static-skeleton h-24 w-full"></div>
|
||||
</div>
|
||||
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
<div className="static-skeleton h-32 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
{/* KPI-Sektion: 4 Statistik-Karten mit Delta + Fortschrittsbalken */}
|
||||
{isAdmin && stats && (
|
||||
<section aria-label="Kennzahlen" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
{/* StatCard: Offene Aufgaben */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Offene Aufgaben</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.openTasks}</span>
|
||||
{stats.totalTasks > 0 && (
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{Math.round((stats.openTasks / stats.totalTasks) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.openTasks / stats.totalTasks) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: Abgeschlossen */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('tasks')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Abgeschlossen</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.completedTasks}</span>
|
||||
{stats.totalTasks > 0 && (
|
||||
<span className="text-xs font-medium text-success">
|
||||
{Math.round((stats.completedTasks / stats.totalTasks) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-success transition-all duration-700" style={{ width: `${stats.totalTasks > 0 ? (stats.completedTasks / stats.totalTasks) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: Aktive Nutzer */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">Aktive Nutzer</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.activeUsers}</span>
|
||||
{stats.totalUsers > 0 && (
|
||||
<span className="text-xs font-medium text-secondary">
|
||||
von {stats.totalUsers}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-secondary transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.activeUsers / stats.totalUsers) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* StatCard: AD-Nutzer */}
|
||||
<div className="card bg-base-200 shadow-sm hover:shadow-md transition-all cursor-pointer" onClick={() => onNavigate?.('users')}>
|
||||
<div className="card-body p-5 space-y-3">
|
||||
<span className="text-xs font-medium opacity-60 uppercase tracking-wider">AD-Nutzer</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums">{stats.adUsers}</span>
|
||||
</div>
|
||||
<div className="h-1 w-full bg-base-300 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-warning transition-all duration-700" style={{ width: `${stats.totalUsers > 0 ? (stats.adUsers / stats.totalUsers) * 100 : 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
{/* Linke Spalte: Top-Vorlagen Rangliste */}
|
||||
{isAdmin && topTemplates.length > 0 && (
|
||||
<section className="lg:col-span-4">
|
||||
<div className="flex items-center justify-between mb-4 h-[38px]">
|
||||
<h2 className="text-xl font-medium tracking-tight">Meistgenutzte Vorlagen</h2>
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{topTemplates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="p-4 flex items-center justify-between hover:bg-base-300/50 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{t.name}</span>
|
||||
<span className="text-xs opacity-60">{t.task_count} Aufgaben</span>
|
||||
</div>
|
||||
<span className={`text-[10px] font-mono py-1 px-2 rounded shrink-0 ml-3 ${
|
||||
t.rank === 1 || t === topTemplates[0]
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'bg-base-300 text-base-content/60'
|
||||
}`}>
|
||||
TOP {topTemplates.indexOf(t) + 1}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */}
|
||||
<section className={isAdmin && topTemplates.length > 0 ? "lg:col-span-8" : "lg:col-span-12"}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4 h-[38px]">
|
||||
<h2 className="text-xl font-medium tracking-tight">{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}</h2>
|
||||
<div className="form-control w-full sm:w-72">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vorlagen durchsuchen..."
|
||||
className="input input-bordered w-full"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
||||
<p className="text-sm opacity-60">Keine Vorlagen vorhanden.</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl p-10 text-center">
|
||||
<p className="text-sm opacity-60">Keine Vorlagen für „{search}" gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{filtered.map((tpl) => {
|
||||
const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length;
|
||||
return (
|
||||
<article key={tpl.id} className="card bg-base-200 shadow-sm hover:shadow-md transition-all rounded-lg p-4 flex flex-col justify-between min-h-32">
|
||||
<div>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<span className="text-[10px] opacity-50 font-mono">{pageCount} Seiten</span>
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-base font-medium line-clamp-2 leading-tight">{tpl.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-3">
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setFillTemplate(tpl)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{fillTemplate && (
|
||||
<FillModal
|
||||
template={fillTemplate}
|
||||
onSubmit={handleSubmitTask}
|
||||
onClose={() => setFillTemplate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/pages/NotFound.jsx
Normal file
20
frontend/src/pages/NotFound.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function NotFound({ onGoHome }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div className="card bg-base-200 shadow-lg max-w-md w-full">
|
||||
<div className="card-body">
|
||||
<h1 className="text-6xl font-bold text-primary mb-2">404</h1>
|
||||
<h2 className="text-xl font-semibold mb-2">Seite nicht gefunden</h2>
|
||||
<p className="text-sm opacity-60 mb-4">
|
||||
Die angeforderte Seite existiert nicht oder wurde verschoben.
|
||||
</p>
|
||||
<button className="btn btn-primary btn-sm" onClick={onGoHome}>
|
||||
Zurück zum Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
268
frontend/src/pages/TasksPage.jsx
Normal file
268
frontend/src/pages/TasksPage.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast, ConfirmModal } from '../components/Toast';
|
||||
import TaskModal from '../components/TaskModal';
|
||||
import { apiFetch, FILE_BASE } from '../utils/api';
|
||||
|
||||
export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [totalTasks, setTotalTasks] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [confirmState, setConfirmState] = useState({ open: false, id: null });
|
||||
|
||||
const fetchTasks = useCallback(async (page = currentPage) => {
|
||||
try {
|
||||
const statusParam = filter !== 'all' ? `&status=${filter}` : '';
|
||||
const res = await apiFetch(`/tasks?page=${page}&limit=20${statusParam}`);
|
||||
const data = await res.json();
|
||||
setTasks(data.tasks || []);
|
||||
setTotalTasks(data.total || 0);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Aufgaben:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
const interval = setInterval(() => fetchTasks(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTasks]);
|
||||
|
||||
const updateStatus = async (taskId, status) => {
|
||||
try {
|
||||
const res = await apiFetch(`/tasks/${taskId}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchTasks();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Aktualisieren:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTask = async (id) => {
|
||||
setConfirmState({ open: true, id });
|
||||
};
|
||||
|
||||
const confirmDeleteTask = async () => {
|
||||
const id = confirmState.id;
|
||||
setConfirmState({ open: false, id: null });
|
||||
try {
|
||||
const res = await apiFetch(`/tasks/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
toast.error('Fehler beim Löschen der Aufgabe.');
|
||||
return;
|
||||
}
|
||||
toast.success('Aufgabe gelöscht.');
|
||||
await fetchTasks();
|
||||
} catch (e) {
|
||||
toast.error('Netzwerkfehler beim Löschen.');
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = searchQuery.trim()
|
||||
? tasks.filter(t =>
|
||||
t.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.user_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.template_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: tasks;
|
||||
|
||||
// Reset page when filter changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
setLoading(true);
|
||||
fetchTasks(1);
|
||||
}, [filter]);
|
||||
|
||||
const goToPage = (page) => {
|
||||
setCurrentPage(page);
|
||||
setLoading(true);
|
||||
fetchTasks(page);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div className="static-skeleton h-8 w-32"></div>
|
||||
<div className="static-skeleton h-10 w-48 rounded-btn"></div>
|
||||
</div>
|
||||
<div className="static-skeleton h-40 w-full"></div>
|
||||
<div className="static-skeleton h-40 w-full"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<h2 className="text-2xl font-bold">Aufgaben</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Punkt 7: Suchleiste */}
|
||||
<div className="form-control">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche..."
|
||||
className="input input-bordered input-sm w-44"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="join">
|
||||
<button className={`btn join-item btn-sm ${filter === 'all' ? 'btn-active' : ''}`} onClick={() => setFilter('all')}>
|
||||
Alle ({totalTasks})
|
||||
</button>
|
||||
<button className={`btn join-item btn-sm ${filter === 'offen' ? 'btn-active' : ''}`} onClick={() => setFilter('offen')}>
|
||||
Offen
|
||||
</button>
|
||||
<button className={`btn join-item btn-sm ${filter === 'erledigt' ? 'btn-active' : ''}`} onClick={() => setFilter('erledigt')}>
|
||||
Erledigt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="hero min-h-[40vh] bg-base-200 rounded-lg">
|
||||
<div className="hero-content text-center">
|
||||
<div>
|
||||
<p className="text-lg opacity-70">
|
||||
{filter === 'all' ? 'Noch keine Aufgaben vorhanden.' : `Keine ${filter === 'offen' ? 'offenen' : 'erledigten'} Aufgaben.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{filtered.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="p-4 sm:p-5 hover:bg-base-300/40 transition-colors cursor-pointer"
|
||||
onClick={() => setSelectedTask(task)}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-bold text-base truncate">{task.title}</h4>
|
||||
<span className="text-sm opacity-50 whitespace-nowrap">
|
||||
{new Date(task.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||
{task.user_name && <span className="text-sm opacity-70">{task.user_name}</span>}
|
||||
{task.template_name && task.title !== task.template_name && (
|
||||
<span className="text-sm opacity-70">{task.template_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* File attachments in row */}
|
||||
{task.file_path && (
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={`${FILE_BASE}${task.file_path}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
|
||||
Dateianhang
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{/* File attachments from task values */}
|
||||
{task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{task.values.filter(v => v.step_type === 'file_upload' && v.value).map(v => (
|
||||
<a
|
||||
key={v.id}
|
||||
href={`${FILE_BASE}${v.value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
|
||||
{v.step_label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
className={`btn btn-sm ${task.status === 'offen' ? 'btn-outline' : 'btn-success'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateStatus(task.id, task.status === 'offen' ? 'erledigt' : 'offen');
|
||||
}}
|
||||
>
|
||||
{task.status === 'offen' ? 'Offen' : 'Erledigt'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm text-error"
|
||||
onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }}
|
||||
title="Aufgabe löschen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTask && (
|
||||
<TaskModal task={selectedTask} currentUserRole={user?.role} onClose={() => setSelectedTask(null)} onTaskUpdated={() => fetchTasks()} />
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title="Aufgabe löschen"
|
||||
message="Möchtest du diese Aufgabe wirklich löschen?"
|
||||
confirmLabel="Löschen"
|
||||
onConfirm={confirmDeleteTask}
|
||||
onCancel={() => setConfirmState({ open: false, id: null })}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-6">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
« Zurück
|
||||
</button>
|
||||
<span className="text-sm opacity-70">
|
||||
Seite {currentPage} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
Weiter »
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
frontend/src/pages/TemplatesPage.jsx
Normal file
191
frontend/src/pages/TemplatesPage.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast, ConfirmModal } from '../components/Toast';
|
||||
import TemplateCard from '../components/TemplateCard';
|
||||
import EditorModal from '../components/EditorModal';
|
||||
import FillModal from '../components/FillModal';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const toast = useToast();
|
||||
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Editor modal state
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState(null);
|
||||
|
||||
// Fill modal state
|
||||
const [showFill, setShowFill] = useState(false);
|
||||
const [fillTemplate, setFillTemplate] = useState(null);
|
||||
|
||||
// Confirm modal state
|
||||
const [confirmState, setConfirmState] = useState({ open: false, id: null });
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/templates');
|
||||
const data = await res.json();
|
||||
setTemplates(data);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Laden der Vorlagen:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
const interval = setInterval(fetchTemplates, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTemplates]);
|
||||
|
||||
const handleOpenEditor = (tpl = null) => {
|
||||
setEditingTemplate(tpl);
|
||||
setShowEditor(true);
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setShowEditor(false);
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (payload) => {
|
||||
const url = editingTemplate
|
||||
? `/templates/${editingTemplate.id}`
|
||||
: '/templates';
|
||||
const method = editingTemplate ? 'PUT' : 'POST';
|
||||
|
||||
const res = await apiFetch(url, {
|
||||
method,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Fehler beim Speichern');
|
||||
}
|
||||
await fetchTemplates();
|
||||
handleCloseEditor();
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (id) => {
|
||||
setConfirmState({ open: true, id });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const id = confirmState.id;
|
||||
setConfirmState({ open: false, id: null });
|
||||
try {
|
||||
const res = await apiFetch(`/templates/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Fehler beim Löschen');
|
||||
return;
|
||||
}
|
||||
toast.success('Vorlage gelöscht.');
|
||||
await fetchTemplates();
|
||||
} catch (e) {
|
||||
toast.error('Netzwerkfehler beim Löschen');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFill = (tpl) => {
|
||||
setFillTemplate(tpl);
|
||||
setShowFill(true);
|
||||
};
|
||||
|
||||
const handleCloseFill = () => {
|
||||
setShowFill(false);
|
||||
setFillTemplate(null);
|
||||
};
|
||||
|
||||
const handleSubmitTask = async (payload) => {
|
||||
const res = await apiFetch('/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, user_id: user.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Fehler beim Absenden');
|
||||
}
|
||||
handleCloseFill();
|
||||
toast.success('Aufgabe erfolgreich abgesendet!');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">Vorlageneditor</h2>
|
||||
{isAdmin && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => handleOpenEditor(null)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
|
||||
Neue Vorlage erstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-200 shadow-sm rounded-xl overflow-hidden">
|
||||
<div className="divide-y divide-base-300">
|
||||
{templates.map((tpl) => (
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
onOpen={handleOpenFill}
|
||||
onEdit={handleOpenEditor}
|
||||
onDelete={handleDeleteTemplate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 && !isAdmin && (
|
||||
<div className="hero min-h-[40vh] bg-base-200 rounded-lg">
|
||||
<div className="hero-content text-center">
|
||||
<div>
|
||||
<p className="text-lg opacity-70">Keine Vorlagen vorhanden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Modal */}
|
||||
{showEditor && (
|
||||
<EditorModal
|
||||
template={editingTemplate}
|
||||
onSave={handleSaveTemplate}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fill Modal */}
|
||||
{showFill && fillTemplate && (
|
||||
<FillModal
|
||||
template={fillTemplate}
|
||||
onSubmit={handleSubmitTask}
|
||||
onClose={handleCloseFill}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
open={confirmState.open}
|
||||
title="Vorlage löschen"
|
||||
message="Möchtest du diese Vorlage wirklich löschen? Alle zugehörigen Aufgaben werden ebenfalls entfernt."
|
||||
confirmLabel="Löschen"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setConfirmState({ open: false, id: null })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
631
frontend/src/pages/UsersPage.jsx
Normal file
631
frontend/src/pages/UsersPage.jsx
Normal file
@@ -0,0 +1,631 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useToast, ConfirmModal } from '../components/Toast';
|
||||
import { apiFetch } from '../utils/api';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const toast = useToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [adStatus, setAdStatus] = useState({ configured: false });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Modal states
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
const [deletingUser, setDeletingUser] = useState(null);
|
||||
// Punkt 9: Rollen-Bestätigung
|
||||
const [roleConfirm, setRoleConfirm] = useState({ open: false, user: null, newRole: '', callback: null });
|
||||
|
||||
// Form states
|
||||
const [formEmail, setFormEmail] = useState('');
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formPassword, setFormPassword] = useState('');
|
||||
const [formRole, setFormRole] = useState('user');
|
||||
const [formStatus, setFormStatus] = useState('aktiv');
|
||||
|
||||
const resetForm = () => {
|
||||
setFormEmail('');
|
||||
setFormName('');
|
||||
setFormPassword('');
|
||||
setFormRole('user');
|
||||
setFormStatus('aktiv');
|
||||
};
|
||||
|
||||
// Bug 3: Remove currentPage/search from deps to prevent infinite re-fetches
|
||||
const fetchUsers = useCallback(async (page, searchQuery) => {
|
||||
try {
|
||||
const p = page ?? currentPage;
|
||||
const sq = searchQuery ?? search;
|
||||
const searchParam = sq.trim() ? `&search=${encodeURIComponent(sq.trim())}` : '';
|
||||
const res = await apiFetch(`/users?page=${p}&limit=${PAGE_SIZE}${searchParam}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Nutzer');
|
||||
const data = await res.json();
|
||||
setUsers(data.users || []);
|
||||
setTotalUsers(data.total || 0);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]); // Only depend on toast
|
||||
|
||||
const fetchADStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/ad/status');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setAdStatus(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
fetchADStatus();
|
||||
}, []); // Bug 3: Empty deps - only run once on mount
|
||||
|
||||
// Debounced search: only trigger when search actually changes and is non-empty or was previously non-empty
|
||||
useEffect(() => {
|
||||
const trimmedSearch = search.trim();
|
||||
// Avoid double load on initial mount (search is empty and fetchUsers already ran)
|
||||
if (trimmedSearch === '' && currentPage === 1) return;
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
setLoading(true);
|
||||
fetchUsers(1, search);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const goToPage = (page) => {
|
||||
setCurrentPage(page);
|
||||
setLoading(true);
|
||||
fetchUsers(page, search);
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
resetForm();
|
||||
setAddOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (user) => {
|
||||
setEditingUser(user);
|
||||
setFormEmail(user.email || '');
|
||||
setFormName(user.name || '');
|
||||
setFormPassword('');
|
||||
setFormRole(user.role);
|
||||
setFormStatus(user.status || 'aktiv');
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = (user) => {
|
||||
if (user.source === 'ad') {
|
||||
toast.addToast('AD-Nutzer können nicht gelöscht werden.', 'error');
|
||||
return;
|
||||
}
|
||||
setDeletingUser(user);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!formEmail.trim() || !formPassword.trim()) return;
|
||||
try {
|
||||
const res = await apiFetch('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: formEmail.trim(),
|
||||
password: formPassword.trim(),
|
||||
name: formName.trim(),
|
||||
role: formRole,
|
||||
status: formStatus,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler beim Anlegen');
|
||||
toast.success('Nutzer erfolgreich angelegt');
|
||||
setAddOpen(false);
|
||||
resetForm();
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
toast.addToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!editingUser) return;
|
||||
// Punkt 9: Rollen-Bestätigung bei Admin-Änderung
|
||||
const roleChanged = formRole !== editingUser.role;
|
||||
const involvesAdmin = formRole === 'admin' || editingUser.role === 'admin';
|
||||
if (roleChanged && involvesAdmin) {
|
||||
return new Promise((resolve) => {
|
||||
setRoleConfirm({
|
||||
open: true,
|
||||
user: editingUser,
|
||||
newRole: formRole,
|
||||
callback: async () => {
|
||||
setRoleConfirm({ open: false, user: null, newRole: '', callback: null });
|
||||
await doHandleEdit();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
await doHandleEdit();
|
||||
};
|
||||
|
||||
const doHandleEdit = async () => {
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
const body = {};
|
||||
if (editingUser.source === 'ad') {
|
||||
// AD users: only role and status
|
||||
body.role = formRole;
|
||||
body.status = formStatus;
|
||||
} else {
|
||||
// Local users: full edit
|
||||
if (!formEmail.trim()) return;
|
||||
body.email = formEmail.trim();
|
||||
body.name = formName.trim();
|
||||
body.role = formRole;
|
||||
body.status = formStatus;
|
||||
if (formPassword.trim()) body.password = formPassword.trim();
|
||||
}
|
||||
const res = await apiFetch(`/users/${editingUser.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler beim Bearbeiten');
|
||||
toast.success('Nutzer erfolgreich aktualisiert');
|
||||
setEditOpen(false);
|
||||
setEditingUser(null);
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
toast.addToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingUser) return;
|
||||
try {
|
||||
const res = await apiFetch(`/users/${deletingUser.id}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Fehler beim Löschen');
|
||||
toast.success('Nutzer erfolgreich gelöscht');
|
||||
setDeleteOpen(false);
|
||||
setDeletingUser(null);
|
||||
fetchUsers();
|
||||
} catch (e) {
|
||||
toast.addToast(e.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Backend filters by status (active only without search, all with search)
|
||||
const localUsers = users.filter(u => u.source !== 'ad');
|
||||
const adUsers = users.filter(u => u.source === 'ad');
|
||||
|
||||
const roleBadgeClass = (role) => {
|
||||
switch (role) {
|
||||
case 'admin': return 'badge-error';
|
||||
case 'user': return 'badge-primary';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadgeClass = (status) => {
|
||||
switch (status) {
|
||||
case 'aktiv': return 'badge-success';
|
||||
case 'inaktiv': return 'badge-warning';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
};
|
||||
|
||||
const getInitials = (user) => {
|
||||
if (user.name) {
|
||||
const parts = user.name.trim().split(/\s+/);
|
||||
return parts.map(p => p[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
return (user.email || '?')[0].toUpperCase();
|
||||
};
|
||||
|
||||
const renderUserCard = (u) => (
|
||||
<div key={u.id} className="card bg-base-100 border border-base-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 no-transition">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="avatar placeholder">
|
||||
<div className={`rounded-full w-10 h-10 flex items-center justify-center ${u.source === 'ad' ? 'bg-secondary text-secondary-content' : 'bg-primary text-primary-content'}`}>
|
||||
<span className="text-sm font-bold">{getInitials(u)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-sm truncate">{u.name || u.email}</p>
|
||||
<p className="text-xs opacity-60 truncate">{u.email}</p>
|
||||
{u.username && (
|
||||
<p className="text-xs opacity-40 truncate">Anmeldename: {u.username}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
className="btn btn-ghost btn-xs btn-square"
|
||||
onClick={() => openEdit(u)}
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
</button>
|
||||
{u.source !== 'ad' && u.id !== currentUser?.id && (
|
||||
<button
|
||||
className="btn btn-ghost btn-xs btn-square text-error hover:bg-error hover:text-error-content"
|
||||
onClick={() => openDelete(u)}
|
||||
title="Löschen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{u.source === 'ad' && (
|
||||
<span className="badge badge-sm badge-secondary">AD</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`badge badge-sm ${roleBadgeClass(u.role)}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
<span className={`badge badge-sm ${statusBadgeClass(u.status || 'aktiv')}`}>
|
||||
{u.status || 'aktiv'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Static skeleton header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="static-skeleton h-8 w-48"></div>
|
||||
<div className="static-skeleton h-4 w-32"></div>
|
||||
</div>
|
||||
<div className="static-skeleton h-9 w-36 rounded-btn"></div>
|
||||
</div>
|
||||
{/* Static skeleton search */}
|
||||
<div className="static-skeleton h-10 w-full max-w-sm rounded-btn mb-4"></div>
|
||||
{/* Static skeleton cards */}
|
||||
<div className="static-skeleton h-40 w-full mb-4"></div>
|
||||
<div className="static-skeleton h-40 w-full"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Nutzerverwaltung</h1>
|
||||
<p className="text-sm opacity-60">{totalUsers} Nutzer insgesamt</p>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
|
||||
Nutzer hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="form-control w-full max-w-sm">
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Name, E-Mail, Rolle oder Anmeldename..."
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{search && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => { setSearch(''); }}>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local Users Card */}
|
||||
<div className="card bg-base-200 mb-4">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
|
||||
<h2 className="card-title text-base">Lokale Nutzer</h2>
|
||||
<span className="badge badge-sm badge-primary">{localUsers.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{localUsers.length === 0 ? (
|
||||
<div className="col-span-full text-center py-6 opacity-50 text-sm">
|
||||
Keine lokalen Nutzer gefunden.
|
||||
</div>
|
||||
) : (
|
||||
localUsers.map(renderUserCard)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AD Users Card */}
|
||||
{adStatus.configured && (
|
||||
<div className="card bg-base-200 mb-4">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
<h2 className="card-title text-base">Active Directory Nutzer</h2>
|
||||
<span className="badge badge-sm badge-secondary">{adUsers.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{adUsers.length === 0 ? (
|
||||
<div className="col-span-full text-center py-6 opacity-50 text-sm">
|
||||
Keine AD-Nutzer gefunden. Die Synchronisation läuft automatisch.
|
||||
</div>
|
||||
) : (
|
||||
adUsers.map(renderUserCard)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
« Zurück
|
||||
</button>
|
||||
<span className="text-sm opacity-70">
|
||||
Seite {currentPage} von {totalPages} ({totalUsers} Nutzer)
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
Weiter »
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Modal */}
|
||||
<dialog className={`modal ${addOpen ? 'modal-open' : ''}`}>
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-4">Neuer Nutzer</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Name</span></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Max Mustermann"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="email"
|
||||
placeholder="z.B. max@beispiel.de"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Passwort</span></label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Passwort vergeben"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Rolle</span></label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Status</span></label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={formStatus}
|
||||
onChange={(e) => setFormStatus(e.target.value)}
|
||||
>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="inaktiv">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { setAddOpen(false); resetForm(); }}>Abbrechen</button>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleAdd}
|
||||
disabled={!formEmail.trim() || !formPassword.trim()}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop"><button onClick={() => { setAddOpen(false); resetForm(); }}>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<dialog className={`modal ${editOpen ? 'modal-open' : ''}`}>
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-4">
|
||||
{editingUser?.source === 'ad' ? 'AD-Nutzer bearbeiten' : 'Nutzer bearbeiten'}
|
||||
</h3>
|
||||
<p className="text-sm opacity-60 mb-4">
|
||||
{editingUser?.source === 'ad'
|
||||
? <>Ändere Rolle und Status von <strong>{editingUser?.name || editingUser?.email}</strong>. Name, E-Mail und Passwort werden aus dem AD synchronisiert.</>
|
||||
: <>Ändere die Daten von <strong>{editingUser?.name || editingUser?.email}</strong>.</>
|
||||
}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
{editingUser?.source === 'ad' ? (
|
||||
<>
|
||||
{/* AD user: show name/email as read-only info */}
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Name</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
|
||||
value={formName}
|
||||
disabled
|
||||
/>
|
||||
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
|
||||
value={formEmail}
|
||||
disabled
|
||||
/>
|
||||
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
|
||||
</div>
|
||||
{editingUser?.username && (
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Anmeldename</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full opacity-60 cursor-not-allowed"
|
||||
value={editingUser.username}
|
||||
disabled
|
||||
/>
|
||||
<label className="label"><span className="label-text-alt opacity-50">Aus Active Directory synchronisiert</span></label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Name</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">E-Mail</span></label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="email"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Neues Passwort</span></label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Leer lassen, um nicht zu ändern"
|
||||
className="input input-bordered input-sm w-full"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Rolle</span></label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label"><span className="label-text font-medium">Status</span></label>
|
||||
<select
|
||||
className="select select-bordered select-sm w-full"
|
||||
value={formStatus}
|
||||
onChange={(e) => setFormStatus(e.target.value)}
|
||||
>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="inaktiv">Inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { setEditOpen(false); setEditingUser(null); }}>Abbrechen</button>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop"><button onClick={() => { setEditOpen(false); setEditingUser(null); }}>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<dialog className={`modal ${deleteOpen ? 'modal-open' : ''}`}>
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">Nutzer löschen</h3>
|
||||
<p className="py-4">
|
||||
Bist du sicher, dass du <strong>{deletingUser?.name || deletingUser?.email}</strong> löschen möchtest? Das kann nicht rückgängig gemacht werden.
|
||||
</p>
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => { setDeleteOpen(false); setDeletingUser(null); }}>Abbrechen</button>
|
||||
<button className="btn btn-sm btn-error" onClick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop"><button onClick={() => { setDeleteOpen(false); setDeletingUser(null); }}>close</button></form>
|
||||
</dialog>
|
||||
|
||||
{/* Punkt 9: Rollen-Bestätigung Modal */}
|
||||
<ConfirmModal
|
||||
open={roleConfirm.open}
|
||||
title="Rolle ändern"
|
||||
message={`Möchtest du die Rolle von ${roleConfirm.user?.name || roleConfirm.user?.email} wirklich zu "${roleConfirm.newRole === 'admin' ? 'Admin' : 'User'}" ändern?${roleConfirm.newRole === 'admin' ? ' Admins haben vollen Zugriff auf alle Funktionen.' : ' Der Nutzer verliert administrative Berechtigungen.'}`}
|
||||
confirmLabel="Bestätigen"
|
||||
onConfirm={roleConfirm.callback}
|
||||
onCancel={() => setRoleConfirm({ open: false, user: null, newRole: '', callback: null })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/utils/api.js
Normal file
65
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Zentrale API-Konfiguration - über Env-Variable konfigurierbar (VITE_API_BASE)
|
||||
// Im Single-Container-Modus: Frontend und Backend auf gleichem Port → relative URL
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
||||
export const FILE_BASE = import.meta.env.VITE_API_BASE
|
||||
? import.meta.env.VITE_API_BASE.replace(/\/api$/, '')
|
||||
: '';
|
||||
|
||||
// P5: CSRF token kept in memory only (not localStorage - not sensitive, but avoids stale tokens)
|
||||
let csrfToken = null;
|
||||
|
||||
export function setCSRFToken(token) {
|
||||
csrfToken = token;
|
||||
}
|
||||
|
||||
export function getCSRFToken() {
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
// P5: Auth relies on HttpOnly cookie only - no token in localStorage
|
||||
export function getAuthHeaders(includeContentType = true) {
|
||||
const headers = {};
|
||||
if (includeContentType) headers['Content-Type'] = 'application/json';
|
||||
// P4: Attach CSRF token for state-changing requests
|
||||
if (csrfToken) headers['x-csrf-token'] = csrfToken;
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Punkt 11: Zentrales Error-Handling mit automatischer 401-Behandlung
|
||||
// und Toast-Benachrichtigung für Serverfehler
|
||||
let globalErrorHandler = null;
|
||||
|
||||
export function setGlobalErrorHandler(handler) {
|
||||
globalErrorHandler = handler;
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = { ...getAuthHeaders(!isFormData), ...(options.headers || {}) };
|
||||
let res;
|
||||
try {
|
||||
// Punkt 8: credentials: 'include' for HttpOnly-Cookie auth
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include' });
|
||||
} catch (err) {
|
||||
// Netzwerkfehler
|
||||
if (globalErrorHandler) globalErrorHandler('network', 'Netzwerkfehler. Bitte Verbindung prüfen.');
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('workflow_user');
|
||||
setCSRFToken(null);
|
||||
window.location.reload();
|
||||
throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
if (globalErrorHandler) globalErrorHandler('rateLimit', 'Zu viele Anfragen. Bitte später erneut versuchen.');
|
||||
}
|
||||
|
||||
if (res.status >= 500) {
|
||||
if (globalErrorHandler) globalErrorHandler('server', 'Serverfehler. Bitte später erneut versuchen.');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user