commit 6be1791c6227bdb10a4cba11f458949f631320fa Author: Kühn Date: Mon Aug 24 09:45:28 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2bc1b8f --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0604e64 --- /dev/null +++ b/.env.example @@ -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= +LDAP_DOMAIN=SEATLE +LDAP_IGNORE_CERT_ERRORS=true +LDAP_BIND_USER= +LDAP_BIND_PASSWORD= +LDAP_SYNC_INTERVAL=300000 +LDAP_FILTER= +LDAP_ATTRIBUTES=mail,displayName,memberOf,distinguishedName,sAMAccountName +LDAP_CREATE_OU= +LDAP_UPN_SUFFIX= + +# ============ Admin Account ============ +ADMIN_EMAIL=admin@workflow.local +ADMIN_INIT_PASSWORD= + +# ============ Server ============ +PORT=5000 +NODE_ENV=production +CORS_ORIGIN=http://localhost:5000 + +# ============ PostgreSQL Database ============ +POSTGRES_DB=workflow +POSTGRES_USER=workflow +POSTGRES_PASSWORD= +# DATABASE_URL wird automatisch aus den Werten oben generiert: +# postgresql://workflow:@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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6e247d --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a0215b4 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..aa15018 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +data \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..01a5a8e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/auditLog.js b/backend/auditLog.js new file mode 100644 index 0000000..77271be --- /dev/null +++ b/backend/auditLog.js @@ -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 }; \ No newline at end of file diff --git a/backend/db.js b/backend/db.js new file mode 100644 index 0000000..1dad4f6 --- /dev/null +++ b/backend/db.js @@ -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; \ No newline at end of file diff --git a/backend/ldapOperations.js b/backend/ldapOperations.js new file mode 100644 index 0000000..5599d62 --- /dev/null +++ b/backend/ldapOperations.js @@ -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 }; \ No newline at end of file diff --git a/backend/ldapSync.js b/backend/ldapSync.js new file mode 100644 index 0000000..8a1e1ae --- /dev/null +++ b/backend/ldapSync.js @@ -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 }; \ No newline at end of file diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 0000000..d7c389b --- /dev/null +++ b/backend/middleware/auth.js @@ -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 +}; \ No newline at end of file diff --git a/backend/middleware/rateLimit.js b/backend/middleware/rateLimit.js new file mode 100644 index 0000000..2500a85 --- /dev/null +++ b/backend/middleware/rateLimit.js @@ -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 }; \ No newline at end of file diff --git a/backend/middleware/validation.js b/backend/middleware/validation.js new file mode 100644 index 0000000..76c4301 --- /dev/null +++ b/backend/middleware/validation.js @@ -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, +}; \ No newline at end of file diff --git a/backend/migrations.js b/backend/migrations.js new file mode 100644 index 0000000..4116f5f --- /dev/null +++ b/backend/migrations.js @@ -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 }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..5008eab --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,1974 @@ +{ + "name": "workflow-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workflow-backend", + "version": "1.0.0", + "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.5.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" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "license": "ISC", + "peerDependencies": { + "express": "^4.16.2" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/ldapts": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-8.2.0.tgz", + "integrity": "sha512-XfKocadpoHc1kNgtbTBBhD+pSh1U9KVTzxcKwDS4ulFFyC7rJkLC3TwIfbQ4rt3my9KHz8qsw9qFXVjcPOGebw==", + "license": "MIT", + "dependencies": { + "strict-event-emitter-types": "2.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c72f8ee --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/routes/ad.js b/backend/routes/ad.js new file mode 100644 index 0000000..51ed3d6 --- /dev/null +++ b/backend/routes/ad.js @@ -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; \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js new file mode 100644 index 0000000..aaceaa5 --- /dev/null +++ b/backend/routes/auth.js @@ -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; \ No newline at end of file diff --git a/backend/routes/stats.js b/backend/routes/stats.js new file mode 100644 index 0000000..3dfc607 --- /dev/null +++ b/backend/routes/stats.js @@ -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; \ No newline at end of file diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js new file mode 100644 index 0000000..0325e53 --- /dev/null +++ b/backend/routes/tasks.js @@ -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; \ No newline at end of file diff --git a/backend/routes/templates.js b/backend/routes/templates.js new file mode 100644 index 0000000..9367dc7 --- /dev/null +++ b/backend/routes/templates.js @@ -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; \ No newline at end of file diff --git a/backend/routes/upload.js b/backend/routes/upload.js new file mode 100644 index 0000000..5a861cc --- /dev/null +++ b/backend/routes/upload.js @@ -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; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js new file mode 100644 index 0000000..522fd0b --- /dev/null +++ b/backend/routes/users.js @@ -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; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..f527eff --- /dev/null +++ b/backend/server.js @@ -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(); \ No newline at end of file diff --git a/backups/workflow_20260727_123700.sql.gz b/backups/workflow_20260727_123700.sql.gz new file mode 100644 index 0000000..cc982cc Binary files /dev/null and b/backups/workflow_20260727_123700.sql.gz differ diff --git a/backups/workflow_20260727_123743.sql.gz b/backups/workflow_20260727_123743.sql.gz new file mode 100644 index 0000000..12a0774 Binary files /dev/null and b/backups/workflow_20260727_123743.sql.gz differ diff --git a/backups/workflow_20260727_123848.sql.gz b/backups/workflow_20260727_123848.sql.gz new file mode 100644 index 0000000..050624a Binary files /dev/null and b/backups/workflow_20260727_123848.sql.gz differ diff --git a/backups/workflow_20260728_060307.sql.gz b/backups/workflow_20260728_060307.sql.gz new file mode 100644 index 0000000..2bfb107 Binary files /dev/null and b/backups/workflow_20260728_060307.sql.gz differ diff --git a/backups/workflow_20260728_061026.sql.gz b/backups/workflow_20260728_061026.sql.gz new file mode 100644 index 0000000..937fbd6 Binary files /dev/null and b/backups/workflow_20260728_061026.sql.gz differ diff --git a/backups/workflow_20260728_111622.sql.gz b/backups/workflow_20260728_111622.sql.gz new file mode 100644 index 0000000..a756edb Binary files /dev/null and b/backups/workflow_20260728_111622.sql.gz differ diff --git a/backups/workflow_20260728_171621.sql.gz b/backups/workflow_20260728_171621.sql.gz new file mode 100644 index 0000000..70ce89f Binary files /dev/null and b/backups/workflow_20260728_171621.sql.gz differ diff --git a/backups/workflow_20260728_231619.sql.gz b/backups/workflow_20260728_231619.sql.gz new file mode 100644 index 0000000..e4be38e Binary files /dev/null and b/backups/workflow_20260728_231619.sql.gz differ diff --git a/backups/workflow_20260729_051618.sql.gz b/backups/workflow_20260729_051618.sql.gz new file mode 100644 index 0000000..3ba95ce Binary files /dev/null and b/backups/workflow_20260729_051618.sql.gz differ diff --git a/backups/workflow_20260729_111615.sql.gz b/backups/workflow_20260729_111615.sql.gz new file mode 100644 index 0000000..33e27a1 Binary files /dev/null and b/backups/workflow_20260729_111615.sql.gz differ diff --git a/backups/workflow_20260730_121825.sql.gz b/backups/workflow_20260730_121825.sql.gz new file mode 100644 index 0000000..229151a Binary files /dev/null and b/backups/workflow_20260730_121825.sql.gz differ diff --git a/backups/workflow_20260730_181824.sql.gz b/backups/workflow_20260730_181824.sql.gz new file mode 100644 index 0000000..236783b Binary files /dev/null and b/backups/workflow_20260730_181824.sql.gz differ diff --git a/backups/workflow_20260731_001822.sql.gz b/backups/workflow_20260731_001822.sql.gz new file mode 100644 index 0000000..eed6a0a Binary files /dev/null and b/backups/workflow_20260731_001822.sql.gz differ diff --git a/backups/workflow_20260731_061821.sql.gz b/backups/workflow_20260731_061821.sql.gz new file mode 100644 index 0000000..32a50be Binary files /dev/null and b/backups/workflow_20260731_061821.sql.gz differ diff --git a/backups/workflow_20260731_070100.sql.gz b/backups/workflow_20260731_070100.sql.gz new file mode 100644 index 0000000..598ff07 Binary files /dev/null and b/backups/workflow_20260731_070100.sql.gz differ diff --git a/backups/workflow_20260731_070705.sql.gz b/backups/workflow_20260731_070705.sql.gz new file mode 100644 index 0000000..aa7e7ee Binary files /dev/null and b/backups/workflow_20260731_070705.sql.gz differ diff --git a/backups/workflow_20260731_070759.sql.gz b/backups/workflow_20260731_070759.sql.gz new file mode 100644 index 0000000..c490a02 Binary files /dev/null and b/backups/workflow_20260731_070759.sql.gz differ diff --git a/backups/workflow_20260731_082025.sql.gz b/backups/workflow_20260731_082025.sql.gz new file mode 100644 index 0000000..7e222b6 Binary files /dev/null and b/backups/workflow_20260731_082025.sql.gz differ diff --git a/backups/workflow_20260731_084457.sql.gz b/backups/workflow_20260731_084457.sql.gz new file mode 100644 index 0000000..99836a7 Binary files /dev/null and b/backups/workflow_20260731_084457.sql.gz differ diff --git a/backups/workflow_20260731_084716.sql.gz b/backups/workflow_20260731_084716.sql.gz new file mode 100644 index 0000000..cc76a3d Binary files /dev/null and b/backups/workflow_20260731_084716.sql.gz differ diff --git a/backups/workflow_20260731_084833.sql.gz b/backups/workflow_20260731_084833.sql.gz new file mode 100644 index 0000000..b1b6f66 Binary files /dev/null and b/backups/workflow_20260731_084833.sql.gz differ diff --git a/backups/workflow_20260731_144831.sql.gz b/backups/workflow_20260731_144831.sql.gz new file mode 100644 index 0000000..890a88b Binary files /dev/null and b/backups/workflow_20260731_144831.sql.gz differ diff --git a/backups/workflow_20260803_075323.sql.gz b/backups/workflow_20260803_075323.sql.gz new file mode 100644 index 0000000..07a7254 Binary files /dev/null and b/backups/workflow_20260803_075323.sql.gz differ diff --git a/backups/workflow_20260803_135322.sql.gz b/backups/workflow_20260803_135322.sql.gz new file mode 100644 index 0000000..1172598 Binary files /dev/null and b/backups/workflow_20260803_135322.sql.gz differ diff --git a/backups/workflow_20260804_085755.sql.gz b/backups/workflow_20260804_085755.sql.gz new file mode 100644 index 0000000..a14d23a Binary files /dev/null and b/backups/workflow_20260804_085755.sql.gz differ diff --git a/backups/workflow_20260804_145754.sql.gz b/backups/workflow_20260804_145754.sql.gz new file mode 100644 index 0000000..444a5c1 Binary files /dev/null and b/backups/workflow_20260804_145754.sql.gz differ diff --git a/backups/workflow_20260804_205753.sql.gz b/backups/workflow_20260804_205753.sql.gz new file mode 100644 index 0000000..5719716 Binary files /dev/null and b/backups/workflow_20260804_205753.sql.gz differ diff --git a/backups/workflow_20260805_025751.sql.gz b/backups/workflow_20260805_025751.sql.gz new file mode 100644 index 0000000..52cf640 Binary files /dev/null and b/backups/workflow_20260805_025751.sql.gz differ diff --git a/backups/workflow_20260805_085748.sql.gz b/backups/workflow_20260805_085748.sql.gz new file mode 100644 index 0000000..34434c6 Binary files /dev/null and b/backups/workflow_20260805_085748.sql.gz differ diff --git a/backups/workflow_20260805_145746.sql.gz b/backups/workflow_20260805_145746.sql.gz new file mode 100644 index 0000000..ea4ca0f Binary files /dev/null and b/backups/workflow_20260805_145746.sql.gz differ diff --git a/backups/workflow_20260805_205745.sql.gz b/backups/workflow_20260805_205745.sql.gz new file mode 100644 index 0000000..bcea629 Binary files /dev/null and b/backups/workflow_20260805_205745.sql.gz differ diff --git a/backups/workflow_20260806_025743.sql.gz b/backups/workflow_20260806_025743.sql.gz new file mode 100644 index 0000000..2399827 Binary files /dev/null and b/backups/workflow_20260806_025743.sql.gz differ diff --git a/backups/workflow_20260806_085741.sql.gz b/backups/workflow_20260806_085741.sql.gz new file mode 100644 index 0000000..c397489 Binary files /dev/null and b/backups/workflow_20260806_085741.sql.gz differ diff --git a/backups/workflow_20260810_062308.sql.gz b/backups/workflow_20260810_062308.sql.gz new file mode 100644 index 0000000..229151a Binary files /dev/null and b/backups/workflow_20260810_062308.sql.gz differ diff --git a/backups/workflow_20260810_122307.sql.gz b/backups/workflow_20260810_122307.sql.gz new file mode 100644 index 0000000..e8f796c Binary files /dev/null and b/backups/workflow_20260810_122307.sql.gz differ diff --git a/backups/workflow_20260810_182306.sql.gz b/backups/workflow_20260810_182306.sql.gz new file mode 100644 index 0000000..aa25039 Binary files /dev/null and b/backups/workflow_20260810_182306.sql.gz differ diff --git a/backups/workflow_20260811_002305.sql.gz b/backups/workflow_20260811_002305.sql.gz new file mode 100644 index 0000000..78ccde7 Binary files /dev/null and b/backups/workflow_20260811_002305.sql.gz differ diff --git a/backups/workflow_20260811_062304.sql.gz b/backups/workflow_20260811_062304.sql.gz new file mode 100644 index 0000000..a7cea34 Binary files /dev/null and b/backups/workflow_20260811_062304.sql.gz differ diff --git a/backups/workflow_20260811_122302.sql.gz b/backups/workflow_20260811_122302.sql.gz new file mode 100644 index 0000000..150e104 Binary files /dev/null and b/backups/workflow_20260811_122302.sql.gz differ diff --git a/backups/workflow_20260811_182301.sql.gz b/backups/workflow_20260811_182301.sql.gz new file mode 100644 index 0000000..b260e2a Binary files /dev/null and b/backups/workflow_20260811_182301.sql.gz differ diff --git a/backups/workflow_20260812_002300.sql.gz b/backups/workflow_20260812_002300.sql.gz new file mode 100644 index 0000000..6b407bc Binary files /dev/null and b/backups/workflow_20260812_002300.sql.gz differ diff --git a/backups/workflow_20260812_062257.sql.gz b/backups/workflow_20260812_062257.sql.gz new file mode 100644 index 0000000..5c13757 Binary files /dev/null and b/backups/workflow_20260812_062257.sql.gz differ diff --git a/backups/workflow_20260812_122256.sql.gz b/backups/workflow_20260812_122256.sql.gz new file mode 100644 index 0000000..6367036 Binary files /dev/null and b/backups/workflow_20260812_122256.sql.gz differ diff --git a/backups/workflow_20260820_100335.sql.gz b/backups/workflow_20260820_100335.sql.gz new file mode 100644 index 0000000..229151a Binary files /dev/null and b/backups/workflow_20260820_100335.sql.gz differ diff --git a/backups/workflow_20260820_160337.sql.gz b/backups/workflow_20260820_160337.sql.gz new file mode 100644 index 0000000..eb7fddf Binary files /dev/null and b/backups/workflow_20260820_160337.sql.gz differ diff --git a/backups/workflow_20260820_220336.sql.gz b/backups/workflow_20260820_220336.sql.gz new file mode 100644 index 0000000..a46367a Binary files /dev/null and b/backups/workflow_20260820_220336.sql.gz differ diff --git a/backups/workflow_20260821_040335.sql.gz b/backups/workflow_20260821_040335.sql.gz new file mode 100644 index 0000000..7dd90e8 Binary files /dev/null and b/backups/workflow_20260821_040335.sql.gz differ diff --git a/backups/workflow_20260821_100334.sql.gz b/backups/workflow_20260821_100334.sql.gz new file mode 100644 index 0000000..f6e65fd Binary files /dev/null and b/backups/workflow_20260821_100334.sql.gz differ diff --git a/backups/workflow_20260821_160332.sql.gz b/backups/workflow_20260821_160332.sql.gz new file mode 100644 index 0000000..d8bd19d Binary files /dev/null and b/backups/workflow_20260821_160332.sql.gz differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..db15952 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..686b06e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Frontend Environment Variables +# API base URL for backend (default: http://localhost:5000/api) +VITE_API_BASE=http://localhost:5000/api \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2572e81 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5b27ec7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + Workflow Portal + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e6f391e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2176 @@ +{ + "name": "workflow-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workflow-frontend", + "version": "1.0.0", + "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.4.32", + "tailwindcss": "^3.3.6", + "vite": "^8.0.16" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/culori": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz", + "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/daisyui": { + "version": "4.12.24", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.12.24.tgz", + "integrity": "sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-selector-tokenizer": "^0.8", + "culori": "^3", + "picocolors": "^1", + "postcss-js": "^4" + }, + "engines": { + "node": ">=16.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..189b636 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..fbe14a4 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..bc2732d --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + // 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 */} +
+ +
+ {isAdmin && ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+ + )} +
+ handleTabChange('dashboard')} /> +
+ + ); + + return ( +
+ + +
+ {/* Mobile navbar */} +
+ + {tabLabels[activeTab] || 'Workflow Portal'} +
+ + {/* Main content */} +
+ +
+ {renderPages()} +
+
+
+
+ + {/* Sidebar */} + +
+ ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/AuthModal.jsx b/frontend/src/components/AuthModal.jsx new file mode 100644 index 0000000..8536207 --- /dev/null +++ b/frontend/src/components/AuthModal.jsx @@ -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 ( +
+
+
+

+ {mode === 'login' ? 'Anmelden' : 'Registrieren'} +

+ +
+ {mode === 'register' && ( +
+ + setName(e.target.value)} + required + /> +
+ )} + +
+ + setEmail(e.target.value)} + required + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + /> + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {success && ( +
+ + {success} +
+ )} + + +
+ +
ODER
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/EditorModal.jsx b/frontend/src/components/EditorModal.jsx new file mode 100644 index 0000000..f00327d --- /dev/null +++ b/frontend/src/components/EditorModal.jsx @@ -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 ( +
e.target === e.currentTarget && onClose()}> +
+

Vorschau: {name}

+
+ {pageEntries.map(([pageNum, steps]) => { + if (steps.length === 0) return null; + return ( +
+ {pageEntries.length > 1 && ( +

Seite {pageNum}

+ )} + {steps.map((step, idx) => ( +
+ + {step.type === 'text_input' && ( + + )} + {step.type === 'checkbox' && ( + + )} + {step.type === 'email' && ( + + )} + {step.type === 'dropdown' && ( + + )} + {step.type === 'ad_password' && ( + + )} +
+ ))} +
+ ); + })} +
+
+ +
+
+
+ ); +} + +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 ( + <> +
e.target === e.currentTarget && handleClose()}> +
+

+ {isEditing ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'} +

+ + {/* Template Name */} +
+ + setName(e.target.value)} + /> +
+ + {/* Is Assignable Toggle */} +
+ +
+ + {/* File Upload Toggle */} +
+ +
+ + {/* AD User Creation Toggle */} +
+ +
+ + {/* Page Tabs */} +
+ {pageNumbers.map((pageNum) => { + const isActive = currentPage === pageNum; + return ( + + ); + })} + +
+ + {/* Steps for current page */} +
+
+ Aufgaben (Seite {currentPage}) + + {currentSteps.length} / {MAX_STEPS_PER_PAGE} + +
+ + + + {currentSteps.length === 0 && ( +
+ Noch keine Aufgaben auf Seite {currentPage}.
+ Klicke auf "+ Aufgabe hinzufügen" oder drücke Enter. +
+ )} + + {currentSteps.map((step, idx) => ( +
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' : '' + }`} + > +
+ {idx + 1} + {/* Drag handle */} + + + updateStep(idx, 'label', e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addStep(); + } + }} + onMouseEnter={() => handleInputHover(true)} + onMouseLeave={() => handleInputHover(false)} + /> + + +
+ + {/* Email configuration */} + {step.type === 'email' && ( +
+
+ + updateStep(idx, 'email_domain', e.target.value)} + onMouseEnter={() => handleInputHover(true)} + onMouseLeave={() => handleInputHover(false)} + /> +
+
+ +
+ + + {allTextSteps.filter((s) => !(s.pageNum === currentPage && s.idx === idx)).length === 0 && ( +

Keine Text-Felder vorhanden. Füge zuerst Text-Felder hinzu.

+ )} +
+
+ {step.email_domain && step.email_source_fields && ( +

+ 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} +

+ )} +
+ )} + + {/* Dropdown configuration */} + {step.type === 'dropdown' && ( +
+
+ + { + 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)} + /> +
+ {step.dropdown_options && ( +
+ {step.dropdown_options.split('|').map((o, i) => o.trim()).filter(Boolean).map((opt, i) => ( + {opt} + ))} +
+ )} +
+ )} + + {/* AD field mapping - shown when ad_create is enabled */} + {adCreate && ( +
+ + +
+ )} + + {/* AD prefix field - shown for address fields */} + {adCreate && ['streetAddress', 'postOfficeBox', 'l', 'st', 'postalCode', 'c'].includes(step.ad_field) && ( +
+ + updateStep(idx, 'ad_prefix', e.target.value)} + onMouseEnter={() => handleInputHover(true)} + onMouseLeave={() => handleInputHover(false)} + /> +
+ )} + + {/* ad_password input field */} + {adCreate && step.ad_field === 'password' && ( +
+ + updateStep(idx, 'dropdown_options', e.target.value)} + onMouseEnter={() => handleInputHover(true)} + onMouseLeave={() => handleInputHover(false)} + /> +
+ )} + {/* ad_password info */} + {adCreate && step.ad_field === 'password' && ( +
+

Wird automatisch als Initialpasswort für den neuen AD-Benutzer verwendet.{step.dropdown_options ? ` Vergebenes Passwort: ${step.dropdown_options}` : ' Standard: InitialPass1!'}

+
+ )} + + {/* displayName configuration */} + {adCreate && step.ad_field === 'displayName' && ( +
+
+ + +
+
+ + +
+
+ + updateStep(idx, 'dropdown_options', e.target.value)} + onMouseEnter={() => handleInputHover(true)} + onMouseLeave={() => handleInputHover(false)} + /> +
+ {step.email_source_fields && ( +

+ 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()})`) : ''} +

+ )} +

Wird als Anzeigename im AD gesetzt (z.B. "Mustermann, Thomas (Amt Leezen)"). Für User nicht sichtbar.

+
+ )} + + {/* sAMAccountName configuration */} + {/* sAMAccountName configuration */} + {adCreate && step.ad_field === 'sAMAccountName' && ( +
+
+ + +
+
+ + +
+ {step.email_source_fields && ( +

+ Vorschau UPN: {(step.email_source_fields || '').split('|').map(f => f.trim()).filter(Boolean).join('.').toLowerCase().replace(/\s+/g, '.')} +

+ )} +

UPN-Format: Vorname.Nachname (z.B. thomas.mustermann). Prä-Windows-2000-Name bleibt NachnameV (z.B. MustermannT).

+
+ )} +
+ ))} + + {currentSteps.length < MAX_STEPS_PER_PAGE && ( + + )} +
+ + {/* Actions */} +
+ + {/* Punkt 8: Vorschau-Button */} + {name.trim() && Object.values(pages).some(p => p.length > 0) && ( + + )} + +
+
+
+ + {/* Punkt 8: Vorschau-Modal */} + {showPreview && setShowPreview(false)} />} + + {/* Punkt 16: Dirty-Check ConfirmModal */} + { setDirtyConfirm(false); onClose(); }} + onCancel={() => setDirtyConfirm(false)} + /> + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..7ba4cfc --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -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 ( +
+
+
+ + + +

Etwas ist schiefgelaufen

+

Ein unerwarteter Fehler ist aufgetreten.

+ +
+
+
+ ); + } + return this.props.children; + } +} \ No newline at end of file diff --git a/frontend/src/components/FillModal.jsx b/frontend/src/components/FillModal.jsx new file mode 100644 index 0000000..e6a0c31 --- /dev/null +++ b/frontend/src/components/FillModal.jsx @@ -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 ( +
e.target === e.currentTarget && handleClose()}> +
+

{template.name}

+ {template.description && ( +

{template.description}

+ )} + + {/* Page Navigation */} + {totalPages > 1 && ( +
+ {pageNumbers.map((page) => { + const count = template.steps.filter((s) => s.page_num === page).length; + return ( + + ); + })} +
+ )} + + {/* Steps - scrollable area */} +
+ {pageSteps.length === 0 && ( +
+ Keine Aufgaben auf Seite {currentPage}. +
+ )} + {pageSteps.map((step) => { + // Hide hidden steps from non-admins + if (!!step.hidden && !isAdmin) return null; + return ( +
+ + {!step.hidden && step.type === 'text_input' && ( + handleChange(step.id, e.target.value)} + /> + )} + {!step.hidden && step.type === 'checkbox' && ( + + )} + {!step.hidden && step.type === 'email' && ( +
+ +

+ Wird automatisch aus "{step.email_source_fields}" und "{step.email_domain}" generiert +

+
+ )} + {!step.hidden && step.type === 'dropdown' && ( + + )} + {/* Admin-only: show auto-generated AD fields as read-only */} + {!!step.hidden && step.ad_field === 'password' && isAdmin && ( +
+ +

AD-Standardpasswort (automatisch)

+
+ )} + {!!step.hidden && step.ad_field === 'displayName' && isAdmin && ( +
+ { + 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 + /> +

AD-Anzeigename (automatisch{step.dropdown_options ? `, Suffix: ${step.dropdown_options}` : ''})

+
+ )} + {!!step.hidden && step.ad_field === 'sAMAccountName' && isAdmin && ( +
+ { + 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 + /> +

AD-Anmeldename (automatisch generiert)

+
+ )} +
+ ); + })} + + {/* File Upload Section - shown on last page if enabled */} + {allowsFileUpload && isLastPage && ( +
+ + handleFileUpload(e.target.files[0])} + /> + {uploadedFileInfo && ( +

+ {uploadedFileInfo.originalname} hochgeladen +

+ )} +
+ )} +
+ + {/* Actions */} +
+ + {!isFirstPage && ( + + )} + {!isLastPage && ( + + )} + {isLastPage && ( + + )} +
+
+ + {/* Punkt 16: Dirty-Check ConfirmModal */} + { setDirtyConfirm(false); onClose(); }} + onCancel={() => setDirtyConfirm(false)} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 0000000..14ff9ba --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -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: ( + + ), + templates: ( + + ), + tasks: ( + + ), + users: ( + + ), + auditlog: ( + + ), +}; + +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 ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/TaskModal.jsx b/frontend/src/components/TaskModal.jsx new file mode 100644 index 0000000..4d7bfdf --- /dev/null +++ b/frontend/src/components/TaskModal.jsx @@ -0,0 +1,1226 @@ +import React, { useEffect, useState } from 'react'; +import { apiFetch, FILE_BASE } from '../utils/api'; +import { useToast, ConfirmModal } from './Toast'; + +const CUSTOM_STEP_TYPES = [ + { value: 'text_input', label: 'Text-Feld' }, + { value: 'checkbox', label: 'Checkbox' }, + { value: 'dropdown', label: 'Dropdown' }, +]; + +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)' }, +]; + +function AddCustomField({ taskId, onFieldAdded, existingValues }) { + const toast = useToast(); + const [showForm, setShowForm] = useState(false); + const [label, setLabel] = useState(''); + const [type, setType] = useState('text_input'); + const [value, setValue] = useState(''); + const [dropdownOptions, setDropdownOptions] = useState(''); + const [adField, setAdField] = useState(''); + const [hidden, setHidden] = useState(false); + const [emailSourceFields, setEmailSourceFields] = useState(''); + const [adding, setAdding] = useState(false); + + // Get all text steps from existing values for source field selection + const allTextSteps = (existingValues || []) + .filter(v => (v.custom_type || v.step_type) === 'text_input' && (v.custom_label || v.step_label || '').trim()) + .map(v => ({ label: v.custom_label || v.step_label, id: v.id })); + + const handleAdd = async () => { + if (!label.trim()) return; + setAdding(true); + try { + const body = { + label: label.trim(), + type, + value: type === 'checkbox' ? '' : value, + page_num: 1, + dropdown_options: type === 'dropdown' ? dropdownOptions : (adField === 'displayName' ? dropdownOptions : ''), + ad_field: adField, + hidden: hidden, + email_source_fields: (adField === 'sAMAccountName' || adField === 'displayName') ? emailSourceFields : '', + }; + const res = await apiFetch(`/tasks/${taskId}/add-field`, { + method: 'POST', + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Fehler beim Hinzufügen'); + } + const newField = await res.json(); + onFieldAdded(newField); + // Reset form + setLabel(''); + setType('text_input'); + setValue(''); + setDropdownOptions(''); + setAdField(''); + setHidden(false); + setEmailSourceFields(''); + setShowForm(false); + } catch (err) { + toast.error(err.message); + } finally { + setAdding(false); + } + }; + + const resetForm = () => { + setShowForm(false); + setLabel(''); + setType('text_input'); + setValue(''); + setDropdownOptions(''); + setAdField(''); + setHidden(false); + setEmailSourceFields(''); + }; + + if (!showForm) { + return ( +
+ +
+ ); + } + + return ( +
+
Neues Feld hinzufügen
+
+
+ + setLabel(e.target.value)} + /> +
+
+ + +
+ + {/* Dropdown options */} + {type === 'dropdown' && ( +
+ + { + const val = e.target.value; + const options = val.split('|').map(o => o.trim()).filter(Boolean); + if (options.length <= 5) setDropdownOptions(val); + }} + /> +
+ )} + {type === 'dropdown' && dropdownOptions && ( +
+ {dropdownOptions.split('|').map(o => o.trim()).filter(Boolean).map((opt, i) => ( + {opt} + ))} +
+ )} + + {/* Value for text_input */} + {type === 'text_input' && ( +
+ + setValue(e.target.value)} + /> +
+ )} + + {/* AD field mapping */} +
+ + +
+ + {/* displayName source fields + suffix */} + {adField === 'displayName' && ( +
+
+ + +
+
+ + +
+
+ + setDropdownOptions(e.target.value)} + /> +
+ {emailSourceFields && ( +

+ Vorschau: {emailSourceFields.split('|').map(f => f.trim()).filter(Boolean).join(', ')}{dropdownOptions ? (dropdownOptions.trim().startsWith('(') ? ` ${dropdownOptions.trim()}` : ` (${dropdownOptions.trim()})`) : ''} +

+ )} +

Wird als Anzeigename im AD gesetzt (z.B. "Mustermann, Thomas (Amt Leezen)").

+
+ )} + + {/* sAMAccountName source fields */} + {adField === 'sAMAccountName' && ( +
+
+ + +
+
+ + +
+ {emailSourceFields && ( +

+ Vorschau UPN: {emailSourceFields.split('|').map(f => f.trim()).filter(Boolean).join('.').toLowerCase().replace(/\s+/g, '.')} +

+ )} +

UPN-Format: Vorname.Nachname. Prä-Windows-2000-Name bleibt NachnameV.

+
+ )} + + {/* Hidden toggle */} +
+ + +
+ +
+ + +
+
+
+ ); +} + +function CopyButton({ text }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + }; + + return ( + + ); +} + +const AD_FIELD_LABELS = { + givenName: 'Vorname', + sn: 'Nachname', + mail: 'E-Mail', + sAMAccountName: 'Anmeldename', + department: 'Abteilung', + telephoneNumber: 'Telefon', + title: 'Position', + physicalDeliveryOfficeName: 'Büro', + company: 'Firma', + displayName: 'Anzeigename', + password: 'Passwort', + description: 'Beschreibung', + wWWHomePage: 'Webseite', + preWin2000: 'Prä-Windows-2000', + streetAddress: 'Straße', + postOfficeBox: 'Postfach', + l: 'Ort', + st: 'Bundesland/Kanton', + postalCode: 'PLZ', + c: 'Land/Region', +}; + +export default function TaskModal({ task, currentUserRole, onClose, onTaskUpdated }) { + const toast = useToast(); + const [activeTab, setActiveTab] = useState('details'); + const [ouTree, setOuTree] = useState([]); + const [ouLoading, setOuLoading] = useState(false); + const [selectedOU, setSelectedOU] = useState(''); + const [adConfig, setAdConfig] = useState(null); + const [adCreating, setAdCreating] = useState(false); + const [adResult, setAdResult] = useState(null); + const [adError, setAdError] = useState(''); + const [isEditing, setIsEditing] = useState(false); + const [editValues, setEditValues] = useState({}); + const [saving, setSaving] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [confirmDeleteField, setConfirmDeleteField] = useState({ open: false, fieldId: null, label: '' }); + const [adProgress, setAdProgress] = useState([]); + // GRP-Gruppen (statisch geladen) + const [groupSearch, setGroupSearch] = useState(''); + const [groupResults, setGroupResults] = useState([]); + const [selectedGroups, setSelectedGroups] = useState([]); + const [allGroups, setAllGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); + // Sicherheitsgruppen-Suche (dynamisch) + const [secGroupQuery, setSecGroupQuery] = useState(''); + const [secGroupResults, setSecGroupResults] = useState([]); + const [secGroupSearching, setSecGroupSearching] = useState(false); + const [secGroupSearchTimer, setSecGroupSearchTimer] = useState(null); + + if (!task) return null; + + // Determine if template has AD creation enabled and user is admin + const adCreate = !!task.ad_create; + const isAdmin = currentUserRole === 'admin'; + const showADTab = adCreate && isAdmin; + const adSteps = (task.values || []).filter((v) => v.ad_field || v.custom_ad_field); + const hasAdFields = adSteps.length > 0; + + // Group values by page_num dynamically (custom fields with null page_num go to page 1) + const pageNumbers = [...new Set((task.values || []).map((v) => v.page_num || 1))].sort((a, b) => a - b); + + // Initialize edit values when entering edit mode + const startEditing = () => { + const initial = {}; + (task.values || []).forEach(v => { + initial[v.id] = { value: v.value || '', is_checked: v.is_checked || 0 }; + }); + setEditValues(initial); + setIsEditing(true); + }; + + const cancelEditing = () => { + setIsEditing(false); + setEditValues({}); + }; + + const handleEditChange = (valueId, field, newValue) => { + setEditValues(prev => ({ + ...prev, + [valueId]: { ...prev[valueId], [field]: newValue } + })); + }; + + const saveEdits = async () => { + setSaving(true); + try { + const updates = Object.entries(editValues).map(([id, data]) => ({ + id: parseInt(id), + value: data.value, + is_checked: data.is_checked + })); + const res = await apiFetch(`/tasks/${task.id}/values`, { + method: 'PUT', + body: JSON.stringify({ values: updates }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Fehler beim Speichern'); + } + // Update local task values + task.values = task.values.map(v => ({ + ...v, + value: editValues[v.id]?.value ?? v.value, + is_checked: editValues[v.id]?.is_checked ?? v.is_checked, + })); + setIsEditing(false); + setEditValues({}); + } catch (err) { + toast.error(err.message); + } finally { + setSaving(false); + } + }; + + // Esc to close + useEffect(() => { + const handleEsc = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handleEsc); + return () => window.removeEventListener('keydown', handleEsc); + }, [onClose]); + + // Punkt 3: AD-Cache - OU-Baum und Gruppen nur einmal laden + useEffect(() => { + if (activeTab === 'ad' && adCreate && ouTree.length === 0 && !ouLoading) { + setOuLoading(true); + setGroupsLoading(true); + Promise.all([ + apiFetch('/ad/create-config').then((r) => r.json()), + apiFetch('/ad/ou-tree').then((r) => r.json()), + apiFetch('/ad/groups-tree').then((r) => r.ok ? r.json() : []), + ]) + .then(([config, tree, groups]) => { + setAdConfig(config); + setOuTree(Array.isArray(tree) ? tree : []); + setAllGroups(Array.isArray(groups) ? groups : []); + if (config.createOU) { + setSelectedOU(config.createOU); + } + }) + .catch((err) => { + setAdError('Fehler beim Laden der AD-Daten: ' + err.message); + }) + .finally(() => { setOuLoading(false); setGroupsLoading(false); }); + } + }, [activeTab, adCreate]); + + // Gruppen-Filter: lokale Suche in den geladenen Gruppen + useEffect(() => { + if (!groupSearch.trim()) { + setGroupResults(allGroups.slice(0, 50)); + } else { + const q = groupSearch.trim().toLowerCase(); + setGroupResults( + allGroups.filter((g) => + (g.cn || '').toLowerCase().includes(q) || + (g.displayName || '').toLowerCase().includes(q) || + (g.sAMAccountName || '').toLowerCase().includes(q) || + (g.description || '').toLowerCase().includes(q) + ).slice(0, 50) + ); + } + }, [groupSearch, allGroups]); + + // Sicherheitsgruppen-Suche (debounced API-Aufruf) + useEffect(() => { + if (!secGroupQuery.trim() || secGroupQuery.trim().length < 2) { + setSecGroupResults([]); + return; + } + if (secGroupSearchTimer) clearTimeout(secGroupSearchTimer); + const timer = setTimeout(async () => { + setSecGroupSearching(true); + try { + const res = await apiFetch(`/ad/groups?q=${encodeURIComponent(secGroupQuery.trim())}`); + if (res.ok) { + const data = await res.json(); + // Bereits ausgewählte Gruppen herausfiltern + setSecGroupResults(Array.isArray(data) ? data.filter(g => !selectedGroups.some(sg => sg.dn === g.dn)) : []); + } + } catch { /* ignore */ } + finally { setSecGroupSearching(false); } + }, 400); + setSecGroupSearchTimer(timer); + return () => clearTimeout(timer); + }, [secGroupQuery]); + + const toggleGroup = (group) => { + setSelectedGroups((prev) => { + if (prev.some((g) => g.dn === group.dn)) { + return prev.filter((g) => g.dn !== group.dn); + } + return [...prev, group]; + }); + }; + + const removeGroup = (dn) => { + setSelectedGroups((prev) => prev.filter((g) => g.dn !== dn)); + }; + + // Build AD field values from task data + const getAdFieldValues = () => { + const fieldMap = {}; + adSteps.forEach((v) => { + const effectiveAdField = v.custom_ad_field || v.ad_field; + if (effectiveAdField && effectiveAdField !== 'password') { + // Apply prefix if set (for address fields) + const prefix = v.ad_prefix || ''; + fieldMap[effectiveAdField] = prefix ? prefix + (v.value || '') : (v.value || ''); + } + }); + // Auto-generate username if not provided + // If source fields are configured: UPN format Vorname.Nachname (e.g. thomas.mustermann) + // Pre-Windows 2000 format: NachnameV (e.g. MustermannT) - always auto-generated + if (!fieldMap.sAMAccountName) { + const samStep = adSteps.find(v => (v.custom_ad_field || v.ad_field) === 'sAMAccountName'); + const sourceFieldsStr = samStep?.custom_email_source_fields || samStep?.email_source_fields || ''; + const sourceFields = sourceFieldsStr.split('|').map(f => f.trim()).filter(Boolean); + if (sourceFields.length > 0) { + // UPN format: Vorname.Nachname from source fields + const sourceValues = sourceFields.map(fieldLabel => { + const sourceStep = (task.values || []).find(v => (v.custom_label || v.step_label) === fieldLabel); + return sourceStep ? (sourceStep.value || '').trim() : ''; + }).filter(Boolean); + if (sourceValues.length > 0) { + fieldMap.sAMAccountName = 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 { + // Fallback: Nachname + FirstInitial (e.g. MustermannT) + const vorname = fieldMap.givenName || ''; + const nachname = fieldMap.sn || ''; + if (vorname && nachname) { + const sanitized = (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, ''); + fieldMap.sAMAccountName = sanitized; + } + } + } + // Auto-generate displayName if not provided: "Nachname, Vorname (Suffix)" + if (!fieldMap.displayName) { + const vorname = fieldMap.givenName || ''; + const nachname = fieldMap.sn || ''; + // Find the displayName step to get the suffix + const displayNameStep = adSteps.find(v => (v.custom_ad_field || v.ad_field) === 'displayName'); + const suffix = (displayNameStep?.custom_dropdown_options || displayNameStep?.dropdown_options || '').trim(); + let baseName = ''; + if (nachname && vorname) { + baseName = nachname + ', ' + vorname; + } else if (nachname) { + baseName = nachname; + } + if (baseName && suffix) { + fieldMap.displayName = baseName + (suffix.startsWith('(') ? ' ' + suffix : ' (' + suffix + ')'); + } else if (baseName) { + fieldMap.displayName = baseName; + } + } + return fieldMap; + }; + + const handleCreateADUser = async () => { + setAdCreating(true); + setAdResult(null); + setAdError(''); + setAdProgress([]); + + // Progress animation steps (visual feedback during creation) + const progressSteps = ['Benutzerobjekt wird erstellt', 'Passwort wird gesetzt', 'Konto wird aktiviert', 'Fertigstellung']; + const startTime = Date.now(); + const stepTimers = []; + progressSteps.forEach((_, i) => { + stepTimers.push(setTimeout(() => setAdProgress(prev => [...prev, i]), (i + 1) * 750)); + }); + + const fieldValues = getAdFieldValues(); + const passwordStep = adSteps.find((v) => (v.custom_ad_field || v.ad_field) === 'password'); + const password = passwordStep ? passwordStep.value : ''; + + const payload = { + ou: selectedOU, + vorname: fieldValues.givenName || '', + nachname: fieldValues.sn || '', + email: fieldValues.mail || '', + username: fieldValues.sAMAccountName || '', + password: password, + department: fieldValues.department || '', + telefon: fieldValues.telephoneNumber || '', + titel: fieldValues.title || '', + displayName: fieldValues.displayName || '', + physicalDeliveryOfficeName: fieldValues.physicalDeliveryOfficeName || '', + company: fieldValues.company || '', + description: fieldValues.description || '', + wWWHomePage: fieldValues.wWWHomePage || '', + streetAddress: fieldValues.streetAddress || '', + postOfficeBox: fieldValues.postOfficeBox || '', + l: fieldValues.l || '', + st: fieldValues.st || '', + postalCode: fieldValues.postalCode || '', + c: fieldValues.c || '', + groups: selectedGroups.map((g) => g.dn), + }; + + try { + const res = await apiFetch('/ad/create-user', { + method: 'POST', + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || 'Unbekannter Fehler beim Anlegen des Benutzers.'); + } + // Wait for all progress steps to complete before hiding animation + const minAnimTime = progressSteps.length * 750; + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, minAnimTime - elapsed); + setTimeout(() => { + setAdResult(data); + setAdCreating(false); + }, remaining); + } catch (err) { + stepTimers.forEach(t => clearTimeout(t)); + setAdError(err.message); + setAdCreating(false); + } + }; + + const renderOUTree = (ous, depth = 0) => { + if (!ous || ous.length === 0) return null; + return ( +
0 ? 'ml-4' : ''}> + {ous.map((ou) => ( +
+ + {ou.children && ou.children.length > 0 && ( +
+ Unter-OUs ({ou.children.length}) + {renderOUTree(ou.children, depth + 1)} +
+ )} +
+ ))} +
+ ); + }; + + const renderValue = (v) => ( +
+
+ {v.custom_label || v.step_label} + {v.custom_label && isAdmin && !isEditing && ( + + )} +
+ {isEditing ? ( + // Edit mode + (v.custom_type || v.step_type) === 'checkbox' ? ( + + ) : (v.custom_type || v.step_type) === 'dropdown' ? ( + + ) : ( + handleEditChange(v.id, 'value', e.target.value)} + /> + ) + ) : ( + // View mode + (v.custom_type || v.step_type) === 'checkbox' ? ( +
+ +
+ ) : (v.custom_type || v.step_type) === 'dropdown' ? ( +
+ {v.ad_prefix ? `${v.ad_prefix}${v.value || ''}` : (v.value || Leer)} + {v.value && } +
+ ) : ( +
+ {v.ad_prefix ? `${v.ad_prefix}${v.value || ''}` : (v.value || Leer)} + {v.value && } +
+ ) + )} +
+ ); + + return ( +
e.target === e.currentTarget && onClose()}> +
+

{task.title}

+
+ {task.user_name &&
User: {task.user_name}
} + {task.template_name && task.title !== task.template_name && ( +
Vorlage: {task.template_name}
+ )} +
+ {task.status === 'offen' ? 'Offen' : 'Erledigt'} +
+
+ {new Date(task.created_at).toLocaleString('de-DE')} +
+
+ + {/* View: Details (default) or AD Actions (after Weiter button) */} + {activeTab === 'details' && ( +
+ {pageNumbers.map((pageNum) => { + const pageValues = (task.values || []).filter((v) => { + // Filter by page (custom fields with null page_num go to page 1) + if ((v.page_num || 1) !== pageNum) return false; + // Hide hidden steps from non-admins + if (!!(v.custom_hidden || v.hidden) && !isAdmin) return false; + return true; + }); + if (pageValues.length === 0) return null; + return ( +
+ {pageNumbers.length > 1 && ( +

Seite {pageNum}

+ )} +
+ {pageValues.map(renderValue)} +
+
+ ); + })} + + {/* File attachment */} + {task.file_path && ( +
+

Dateianhang

+ +
+ )} + + {(!task.values || task.values.length === 0) && !task.file_path && ( +
Keine Werte vorhanden.
+ )} + + {/* Admin: Add custom field (only in edit mode) */} + {isAdmin && isEditing && { + // Reload task from server after adding a field + try { + const res = await apiFetch(`/tasks/${task.id}`); + if (res.ok) { + const updatedTask = await res.json(); + Object.assign(task, updatedTask); + if (onTaskUpdated) onTaskUpdated(updatedTask); + } + } catch (e) { + console.error('Fehler beim Neuladen der Aufgabe:', e); + } + }} />} +
+ )} + + {/* AD Actions View (after Weiter button) */} + {activeTab === 'ad' && showADTab && ( +
+ + {/* GRP-Gruppen (statisch geladen) */} +
+

+ + GRP-Gruppen +

+

GRP-Gruppen werden automatisch geladen. Filtere oder wähle direkt aus.

+ + {/* Suchfeld zum Filtern der GRP-Gruppen */} +
+ setGroupSearch(e.target.value)} + /> +
+ + {/* GRP-Gruppen-Liste */} + {groupsLoading ? ( +
+ + GRP-Gruppen werden geladen... +
+ ) : allGroups.length === 0 ? ( +
+

Keine GRP-Gruppen gefunden. Stelle sicher, dass LDAP konfiguriert ist.

+
+ ) : ( +
+ {groupResults.length === 0 && groupSearch.trim() ? ( +

Keine GRP-Gruppen gefunden für "{groupSearch}"

+ ) : ( + groupResults.map((group) => { + const isSelected = selectedGroups.some((g) => g.dn === group.dn); + return ( + + ); + }) + )} +
+ )} +
+ + {/* Sicherheitsgruppen-Suche (dynamisch) */} +
+

+ + Weitere Sicherheitsgruppen suchen +

+

Hier kannst du nach beliebigen Sicherheitsgruppen suchen (z.B. ACL_ etc.). Min. 2 Zeichen eingeben.

+ +
+ setSecGroupQuery(e.target.value)} + /> +
+ + {secGroupSearching && ( +
+ + Suche... +
+ )} + + {secGroupResults.length > 0 && ( +
+ {secGroupResults.map((group) => ( +
+ +
+
{group.displayName || group.cn}
+ {group.description &&
{group.description}
} +
+
+ ))} +
+ )} + + {secGroupQuery.trim().length >= 2 && !secGroupSearching && secGroupResults.length === 0 && ( +

Keine Sicherheitsgruppen gefunden für "{secGroupQuery}"

+ )} +
+ + {/* Ausgewählte Gruppen (GRP + Sicherheitsgruppen) */} + {selectedGroups.length > 0 && ( +
+

Ausgewählte Gruppen ({selectedGroups.length})

+
+ {selectedGroups.map((group) => ( +
+ + {group.displayName || group.cn} + +
+ ))} +
+
+ )} + + {/* OU Selection Tree */} +
+

Ziel-OU auswählen

+ {ouLoading ? ( +
+ + OU-Baum wird geladen... +
+ ) : ouTree.length > 0 ? ( +
+ {renderOUTree(ouTree)} +
+ ) : ( +
+

Keine OU-Struktur verfügbar. Bitte LDAP_CREATE_OU in der .env konfigurieren.

+
+ )} +
+ + {/* Selected OU + Create Button */} +
+
+
+ Gewählte OU: +

{selectedOU || '— keine ausgewählt —'}

+
+ +
+
+ + {/* Sub-Modal for progress animation and result */} + {adCreating && ( +
+
+

Benutzer wird im AD angelegt

+

Bitte warten...

+ + {/* Custom fluid progress bar */} +
+
+
+
+
+ + {/* Custom step list with fluid checkmarks */} +
+ {['Benutzerobjekt wird erstellt', 'Passwort wird gesetzt', 'Konto wird aktiviert', 'Fertigstellung'].map((step, i) => { + const isDone = adProgress.length > i; + const isCurrent = adProgress.length === i; + return ( +
+
+ {isDone ? ( + + ) : isCurrent ? ( + + ) : ( + {i + 1} + )} +
+ + {step} + +
+ ); + })} +
+
+
+ )} + + {/* Sub-Modal for result */} + {adResult && !adCreating && ( +
setAdResult(null)}> +
e.stopPropagation()}> +
+ + Benutzer erfolgreich im AD angelegt! +
+ {adResult.groupResults && adResult.groupResults.length > 0 && ( +
+

Gruppenmitgliedschaften:

+
+ {adResult.groupResults.map((gr, i) => ( +
+ {gr.status === 'added' && ( + + )} + {gr.status === 'already_member' && ( + + )} + {gr.status === 'error' && ( + + )} + {gr.dn.split(',')[0]} + {gr.status === 'added' && (hinzugefügt)} + {gr.status === 'already_member' && (bereits Mitglied)} + {gr.status === 'error' && (Fehler)} +
+ ))} +
+
+ )} +
+ +
+
+
+ )} + + {/* Sub-Modal for error */} + {adError && !adCreating && ( +
setAdError('')}> +
e.stopPropagation()}> +
+ + Anlegen fehlgeschlagen: {adError} +
+
+ +
+
+
+ )} +
+ )} + +
+ {activeTab === 'ad' && ( + + )} + {activeTab === 'details' && showADTab && !isEditing && ( + + )} + {isAdmin && activeTab === 'details' && !isEditing && ( + + )} + {isEditing && ( + <> + + + + )} + +
+
+ + {/* ConfirmModal for field deletion */} + { + const { fieldId } = confirmDeleteField; + setConfirmDeleteField({ open: false, fieldId: null, label: '' }); + setRefreshing(true); + try { + const res = await apiFetch(`/tasks/${task.id}/fields/${fieldId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + toast.success('Feld gelöscht.'); + const refreshRes = await apiFetch(`/tasks/${task.id}`); + if (refreshRes.ok) { + const updatedTask = await refreshRes.json(); + Object.assign(task, updatedTask); + if (onTaskUpdated) onTaskUpdated(updatedTask); + } + } catch (err) { + toast.error(err.message); + } finally { + setRefreshing(false); + } + }} + onCancel={() => setConfirmDeleteField({ open: false, fieldId: null, label: '' })} + /> + + {/* Loading overlay when refreshing data */} + {refreshing && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/TemplateCard.jsx b/frontend/src/components/TemplateCard.jsx new file mode 100644 index 0000000..b4afe9b --- /dev/null +++ b/frontend/src/components/TemplateCard.jsx @@ -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 ( +
+
+
+
+

{template.name}

+ {pageCount > 1 && ( + {pageCount} Seiten + )} +
+

+ {template.description || 'Keine Beschreibung'} +

+
+
+ {isAdmin && ( + <> + + + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 0000000..ffd85e4 --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -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 ( +
+
+ {toast.type === 'success' && ( + + )} + {toast.type === 'error' && ( + + )} + {toast.type === 'warning' && ( + + )} + {toast.type === 'info' && ( + + )} + {toast.message} +
+ +
+ ); +} + +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 ( + + {children} + {/* Punkt 14: Toast container responsive - avoids sidebar overlap on lg screens */} +
+ {toasts.map((toast) => ( + + ))} +
+
+ ); +} + +/* Confirm Modal - replaces browser confirm() */ +export function ConfirmModal({ open, title, message, confirmLabel, cancelLabel, onConfirm, onCancel }) { + if (!open) return null; + return ( +
e.target === e.currentTarget && onCancel()}> +
+

{title || 'Bestätigung'}

+

{message}

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx new file mode 100644 index 0000000..4f3ea49 --- /dev/null +++ b/frontend/src/context/AuthContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..7ed7f5c --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..54b39dd --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + , +) diff --git a/frontend/src/pages/AuditLogPage.jsx b/frontend/src/pages/AuditLogPage.jsx new file mode 100644 index 0000000..eb19cd2 --- /dev/null +++ b/frontend/src/pages/AuditLogPage.jsx @@ -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 ( +
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

Audit-Log

+

{total} Einträge

+ + {/* Punkt 17: Filter Controls */} +
+
+ + +
+
+ + setFilterUser(e.target.value)} + /> +
+
+ + setFilterDateFrom(e.target.value)} + /> +
+
+ + setFilterDateTo(e.target.value)} + /> +
+ +
+ + {entries.length === 0 ? ( +
+
+

Keine Audit-Einträge vorhanden.

+
+
+ ) : ( + <> +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
ZeitpunktNutzerAktionEntitätDetails
+ {new Date(entry.created_at).toLocaleString('de-DE')} + {entry.user_name || 'System'} + {formatAction(entry.action)} + + {formatEntityType(entry.entity_type)} + {entry.entity_id ? ` #${entry.entity_id}` : ''} + + {entry.details || '—'} +
+
+ + {totalPages > 1 && ( +
+ + + Seite {page} von {totalPages} + + +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..4f7216a --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -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 ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* KPI-Sektion: 4 Statistik-Karten mit Delta + Fortschrittsbalken */} + {isAdmin && stats && ( +
+ {/* StatCard: Offene Aufgaben */} +
onNavigate?.('tasks')}> +
+ Offene Aufgaben +
+ {stats.openTasks} + {stats.totalTasks > 0 && ( + + {Math.round((stats.openTasks / stats.totalTasks) * 100)}% + + )} +
+
+
0 ? (stats.openTasks / stats.totalTasks) * 100 : 0}%` }} /> +
+
+
+ + {/* StatCard: Abgeschlossen */} +
onNavigate?.('tasks')}> +
+ Abgeschlossen +
+ {stats.completedTasks} + {stats.totalTasks > 0 && ( + + {Math.round((stats.completedTasks / stats.totalTasks) * 100)}% + + )} +
+
+
0 ? (stats.completedTasks / stats.totalTasks) * 100 : 0}%` }} /> +
+
+
+ + {/* StatCard: Aktive Nutzer */} +
onNavigate?.('users')}> +
+ Aktive Nutzer +
+ {stats.activeUsers} + {stats.totalUsers > 0 && ( + + von {stats.totalUsers} + + )} +
+
+
0 ? (stats.activeUsers / stats.totalUsers) * 100 : 0}%` }} /> +
+
+
+ + {/* StatCard: AD-Nutzer */} +
onNavigate?.('users')}> +
+ AD-Nutzer +
+ {stats.adUsers} +
+
+
0 ? (stats.adUsers / stats.totalUsers) * 100 : 0}%` }} /> +
+
+
+
+ )} + + {/* Haupt-Grid: Top-Vorlagen (links) | Vorlagen mit Suche (rechts) */} +
+ {/* Linke Spalte: Top-Vorlagen Rangliste */} + {isAdmin && topTemplates.length > 0 && ( +
+
+

Meistgenutzte Vorlagen

+
+
+
+ {topTemplates.map((t) => ( +
+
+ {t.name} + {t.task_count} Aufgaben +
+ + TOP {topTemplates.indexOf(t) + 1} + +
+ ))} +
+
+
+ )} + + {/* Rechte Spalte: Vorlagen mit Suchleiste + Grid */} +
0 ? "lg:col-span-8" : "lg:col-span-12"}> +
+

{isAdmin ? 'Vorlagen' : 'Verfügbare Vorlagen'}

+
+ setSearch(e.target.value)} + /> +
+
+ + {templates.length === 0 ? ( +
+

Keine Vorlagen vorhanden.

+
+ ) : filtered.length === 0 ? ( +
+

Keine Vorlagen für „{search}" gefunden.

+
+ ) : ( +
+ {filtered.map((tpl) => { + const pageCount = [...new Set(tpl.steps?.map((s) => s.page_num) || [])].length; + return ( +
+
+ {pageCount > 1 && ( +
+ {pageCount} Seiten +
+ )} +

{tpl.name}

+
+
+ +
+
+ ); + })} +
+ )} +
+
+ + {fillTemplate && ( + setFillTemplate(null)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/NotFound.jsx b/frontend/src/pages/NotFound.jsx new file mode 100644 index 0000000..ae34636 --- /dev/null +++ b/frontend/src/pages/NotFound.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export default function NotFound({ onGoHome }) { + return ( +
+
+
+

404

+

Seite nicht gefunden

+

+ Die angeforderte Seite existiert nicht oder wurde verschoben. +

+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/TasksPage.jsx b/frontend/src/pages/TasksPage.jsx new file mode 100644 index 0000000..d81f9d3 --- /dev/null +++ b/frontend/src/pages/TasksPage.jsx @@ -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 ( +
+
+
+
+
+
+
+
+ ); + } + + return ( +
+
+

Aufgaben

+
+ {/* Punkt 7: Suchleiste */} +
+ setSearchQuery(e.target.value)} + /> +
+
+ + + +
+
+
+ + {filtered.length === 0 ? ( +
+
+
+

+ {filter === 'all' ? 'Noch keine Aufgaben vorhanden.' : `Keine ${filter === 'offen' ? 'offenen' : 'erledigten'} Aufgaben.`} +

+
+
+
+ ) : ( +
+
+ {filtered.map((task) => ( +
setSelectedTask(task)} + > +
+
+
+

{task.title}

+ + {new Date(task.created_at).toLocaleDateString('de-DE')} + +
+
+ {task.user_name && {task.user_name}} + {task.template_name && task.title !== task.template_name && ( + {task.template_name} + )} +
+ {/* File attachments in row */} + {task.file_path && ( + + )} + {/* File attachments from task values */} + {task.values?.filter(v => v.step_type === 'file_upload' && v.value).length > 0 && ( +
+ {task.values.filter(v => v.step_type === 'file_upload' && v.value).map(v => ( + e.stopPropagation()} + > + + {v.step_label} + + ))} +
+ )} +
+
+ + +
+
+
+ ))} +
+
+ )} + + {selectedTask && ( + setSelectedTask(null)} onTaskUpdated={() => fetchTasks()} /> + )} + + {/* Confirm Delete Modal */} + setConfirmState({ open: false, id: null })} + /> + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Seite {currentPage} von {totalPages} + + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/TemplatesPage.jsx b/frontend/src/pages/TemplatesPage.jsx new file mode 100644 index 0000000..d6bcdd1 --- /dev/null +++ b/frontend/src/pages/TemplatesPage.jsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+

Vorlageneditor

+ {isAdmin && ( + + )} +
+ +
+
+ {templates.map((tpl) => ( + + ))} +
+
+ + {templates.length === 0 && !isAdmin && ( +
+
+
+

Keine Vorlagen vorhanden.

+
+
+
+ )} + + {/* Editor Modal */} + {showEditor && ( + + )} + + {/* Fill Modal */} + {showFill && fillTemplate && ( + + )} + + {/* Confirm Delete Modal */} + setConfirmState({ open: false, id: null })} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/UsersPage.jsx b/frontend/src/pages/UsersPage.jsx new file mode 100644 index 0000000..30405a7 --- /dev/null +++ b/frontend/src/pages/UsersPage.jsx @@ -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) => ( +
+
+
+
+
+
+ {getInitials(u)} +
+
+
+

{u.name || u.email}

+

{u.email}

+ {u.username && ( +

Anmeldename: {u.username}

+ )} +
+
+
+ + {u.source !== 'ad' && u.id !== currentUser?.id && ( + + )} + {u.source === 'ad' && ( + AD + )} +
+
+
+ + {u.role} + + + {u.status || 'aktiv'} + +
+
+
+ ); + + if (loading) { + return ( +
+ {/* Static skeleton header */} +
+
+
+
+
+
+
+ {/* Static skeleton search */} +
+ {/* Static skeleton cards */} +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Nutzerverwaltung

+

{totalUsers} Nutzer insgesamt

+
+ +
+ + {/* Search */} +
+
+
+ { setSearch(e.target.value); }} + /> +
+
+ {search && ( + + )} +
+ + {/* Local Users Card */} +
+
+
+ +

Lokale Nutzer

+ {localUsers.length} +
+
+ {localUsers.length === 0 ? ( +
+ Keine lokalen Nutzer gefunden. +
+ ) : ( + localUsers.map(renderUserCard) + )} +
+
+
+ + {/* AD Users Card */} + {adStatus.configured && ( +
+
+
+ +

Active Directory Nutzer

+ {adUsers.length} +
+
+ {adUsers.length === 0 ? ( +
+ Keine AD-Nutzer gefunden. Die Synchronisation läuft automatisch. +
+ ) : ( + adUsers.map(renderUserCard) + )} +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Seite {currentPage} von {totalPages} ({totalUsers} Nutzer) + + +
+ )} + + {/* Add Modal */} + +
+

Neuer Nutzer

+
+
+ + setFormName(e.target.value)} + /> +
+
+ + setFormEmail(e.target.value)} + /> +
+
+ + setFormPassword(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + {/* Edit Modal */} + +
+

+ {editingUser?.source === 'ad' ? 'AD-Nutzer bearbeiten' : 'Nutzer bearbeiten'} +

+

+ {editingUser?.source === 'ad' + ? <>Ändere Rolle und Status von {editingUser?.name || editingUser?.email}. Name, E-Mail und Passwort werden aus dem AD synchronisiert. + : <>Ändere die Daten von {editingUser?.name || editingUser?.email}. + } +

+
+ {editingUser?.source === 'ad' ? ( + <> + {/* AD user: show name/email as read-only info */} +
+ + + +
+
+ + + +
+ {editingUser?.username && ( +
+ + + +
+ )} + + ) : ( + <> +
+ + setFormName(e.target.value)} + /> +
+
+ + setFormEmail(e.target.value)} + /> +
+
+ + setFormPassword(e.target.value)} + /> +
+ + )} +
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + {/* Delete Modal */} + +
+

Nutzer löschen

+

+ Bist du sicher, dass du {deletingUser?.name || deletingUser?.email} löschen möchtest? Das kann nicht rückgängig gemacht werden. +

+
+ + +
+
+
+
+ + {/* Punkt 9: Rollen-Bestätigung Modal */} + setRoleConfirm({ open: false, user: null, newRole: '', callback: null })} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js new file mode 100644 index 0000000..9e745df --- /dev/null +++ b/frontend/src/utils/api.js @@ -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; +} \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..0a13900 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,14 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [require('daisyui')], + daisyui: { + themes: ["light", "dark"], + }, +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..c852c50 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, +}); \ No newline at end of file diff --git a/info.md b/info.md new file mode 100644 index 0000000..c019c17 --- /dev/null +++ b/info.md @@ -0,0 +1,165 @@ +# Workflow Portal – Dokumentation + +## Übersicht + +Das Workflow Portal ist eine Web-Anwendung zur Verwaltung von Arbeitsvorlagen und Aufgaben, mit integrierter Active Directory / LDAP-Anbindung für die Nutzerverwaltung. + +--- + +## Architektur + +| Komponente | Technologie | Port | +|---|---|---| +| **Backend** | Express.js + SQLite3 + ldapjs | 5000 | +| **Frontend** | React 18 + Vite + TailwindCSS + DaisyUI | 5173 | +| **Prisma Studio** | Prisma v5.22.0 | 5555 | +| **Datenbank** | SQLite (`backend/data/workflow.db`) | – | + +--- + +## Nutzerarten + +### Lokale Nutzer +- Werden manuell im Portal angelegt +- Anmeldung mit **E-Mail + Passwort** +- Vollständig bearbeitbar (Name, E-Mail, Passwort, Rolle, Status) +- Können gelöscht werden + +### Active Directory Nutzer +- Werden automatisch aus dem AD/LDAP synchronisiert +- Anmeldung mit **AD-Benutzeranmeldename (sAMAccountName) + AD-Passwort** +- Die Authentifizierung erfolgt direkt gegen den LDAP-Server +- **Bearbeitbar im Portal:** Rolle (admin/user) und Status (aktiv/inaktiv) +- **Schreibgeschützt:** Name, E-Mail, Anmeldename, Passwort (werden aus dem AD synchronisiert) +- **Nicht löschbar** im Portal – müssen im AD entfernt werden + +> **Wichtig:** Lokal geänderte Rollen und Status werden beim nächsten LDAP-Sync **nicht** überschrieben. Name, E-Mail und Anmeldename werden jedoch bei jedem Sync aktualisiert. + +--- + +## LDAP-Konfiguration + +Die LDAP-Einstellungen werden über Umgebungsvariablen in der `.env`-Datei konfiguriert: + +| Variable | Beschreibung | Beispiel | +|---|---|---| +| `LDAP_SERVER` | Hostname des LDAP-Servers | `PIDC02.seatle.intra` | +| `LDAP_PORT` | Port (389=LDAP, 636=LDAPS) | `636` | +| `LDAP_SEARCH_BASE` | Suchbasis für Nutzer | `DC=SEATLE,DC=INTRA` | +| `LDAP_DOMAIN` | Domäne (für Referenz) | `SEATLE` | +| `LDAP_IGNORE_CERT_ERRORS` | Zertifikatsprüfung überspringen | `true` / `false` | +| `LDAP_BIND_USER` | Service-Account (user@domain) | `MustermannM@seatle.intra` | +| `LDAP_BIND_PASSWORD` | Passwort des Service-Accounts | `Verwaltung1!` | +| `LDAP_SYNC_INTERVAL` | Sync-Intervall in ms (Standard: 300000 = 5 Min) | `300000` | +| `LDAP_FILTER` | LDAP-Suchfilter (leer = Standard) | leer | +| `LDAP_ATTRIBUTES` | LDAP-Attribute (kommagetrennt) | `mail,displayName,memberOf,distinguishedName,sAMAccountName` | + +--- + +## Anmeldung + +### Lokaler Admin +- **E-Mail:** `admin@workflow.local` +- **Passwort:** `1337` + +### AD-Nutzer +- **Anmeldename:** sAMAccountName aus dem AD (z.B. `MustermannM`) +- **Passwort:** Das AD-Passwort des Nutzers +- Die Anmeldung erfolgt per LDAP-Bind direkt gegen den AD-Server + +### Status-basierte Zugangsbeschränkung +- **aktiv:** Anmeldung erlaubt +- **inaktiv:** Anmeldung wird blockiert (gilt für lokale UND AD-Nutzer) + +--- + +## Rollen + +| Rolle | Berechtigungen | +|---|---| +| `admin` | Vollzugriff: Vorlagen erstellen/bearbeiten, Nutzerverwaltung, alle Aufgaben einsehen | +| `user` | Eingeschränkter Zugriff: Zugewiesene Vorlagen ausfüllen, eigene Aufgaben einsehen | + +--- + +## LDAP-Sync Verhalten + +Der Sync läuft automatisch alle 5 Minuten (konfigurierbar über `LDAP_SYNC_INTERVAL`). + +### Was synchronisiert wird: +- **Neue AD-Nutzer** → werden in der Datenbank angelegt (Status: aktiv, Rolle: abgeleitet aus AD-Gruppen) +- **Bestehende AD-Nutzer** → Name und Anmeldename werden aktualisiert +- **Entfernte AD-Nutzer** → werden aus der Datenbank gelöscht (wenn sie nicht mehr im AD gefunden werden) + +### Was NICHT überschrieben wird: +- **Rolle** (admin/user) – lokal geänderte Rollen bleiben erhalten +- **Status** (aktiv/inaktiv) – lokal geänderte Status bleiben erhalten + +### Rollen-Ableitung aus AD-Gruppen: +- Nutzer in einer Admin-Gruppe (`admin`, `Domain Admins`, `Domänen-Admins`) → Rolle `admin` +- Alle anderen → Rolle `user` + +--- + +## Docker + +### Container starten +```bash +docker compose up -d --build +``` + +### Container stoppen +```bash +docker compose down +``` + +### Backend-Logs anzeigen +```bash +docker compose logs backend --tail 50 +``` + +### Datenbank zurücksetzen +```bash +docker compose down +rm backend/data/workflow.db +docker compose up -d --build +``` + +--- + +## API-Endpunkte + +### Auth +| Methode | Endpunkt | Beschreibung | +|---|---|---| +| POST | `/api/auth/login` | Anmeldung (E-Mail/Username + Passwort) | +| POST | `/api/auth/register` | Neuen lokalen Nutzer registrieren | + +### Nutzer +| Methode | Endpunkt | Beschreibung | +|---|---|---| +| GET | `/api/users` | Alle Nutzer auflisten | +| POST | `/api/users` | Neuen lokalen Nutzer anlegen | +| PUT | `/api/users/:id` | Nutzer bearbeiten (AD: nur Rolle/Status) | +| DELETE | `/api/users/:id` | Lokalen Nutzer löschen (AD-Nutzer: 403) | + +### AD-Status +| Methode | Endpunkt | Beschreibung | +|---|---|---| +| GET | `/api/ad/status` | LDAP-Konfigurationsstatus | + +### Vorlagen & Aufgaben +| Methode | Endpunkt | Beschreibung | +|---|---|---| +| GET/POST | `/api/templates` | Vorlagen auflisten/erstellen | +| GET/PUT/DELETE | `/api/templates/:id` | Vorlage abrufen/ändern/löschen | +| GET/POST | `/api/tasks` | Aufgaben auflisten/erstellen | +| PUT | `/api/tasks/:id` | Aufgabe aktualisieren | + +--- + +## Sicherheitshinweise + +- **LDAP_IGNORE_CERT_ERRORS=true** deaktiviert die Zertifikatsprüfung. In Produktion sollte das CA-Zertifikat des LDAP-Servers im Container installiert werden. +- Passwörter werden als Klartext in der SQLite-Datenbank gespeichert (nur für lokale Nutzer). Für Produktion sollte bcrypt o.ä. verwendet werden. +- AD-Passwörter werden **nicht** gespeichert – die Authentifizierung erfolgt direkt per LDAP-Bind. \ No newline at end of file