commit 705329d3c21be9dc4e57d5c54f90dfebd914ccc8 Author: Carsten Graf Date: Sun Mar 22 19:26:35 2026 +0100 Inital Commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..be5ef42 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL=postgresql://crm:crm@localhost:5432/crm +PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb47ca2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +.env +*.log +.DS_Store diff --git a/Anlagenliste ITT.xlsx b/Anlagenliste ITT.xlsx new file mode 100644 index 0000000..8eb00d8 Binary files /dev/null and b/Anlagenliste ITT.xlsx differ diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..f120a06 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,75 @@ +-- SQLite-Schema (CRM) — nur Maschinen, Tickets, Events (keine Kunden-Tabelle) +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS "machines" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "typ" TEXT NOT NULL, + "seriennummer" TEXT NOT NULL, + "standort" TEXT NOT NULL, + "extras" TEXT, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS "tickets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "machine_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'OPEN' CHECK ("status" IN ('OPEN', 'WAITING', 'DONE')), + "priority" TEXT NOT NULL DEFAULT 'MEDIUM' CHECK ("priority" IN ('LOW', 'MEDIUM', 'HIGH')), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY ("machine_id") REFERENCES "machines" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +CREATE TABLE IF NOT EXISTS "events" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ticket_id" TEXT NOT NULL, + "type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM')), + "description" TEXT NOT NULL, + "callback_number" TEXT, + "teamviewer_id" TEXT, + "article_number" TEXT, + "remote_duration_seconds" INTEGER, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX IF NOT EXISTS "tickets_machine_id_idx" ON "tickets" ("machine_id"); +CREATE INDEX IF NOT EXISTS "tickets_status_idx" ON "tickets" ("status"); +CREATE INDEX IF NOT EXISTS "tickets_priority_idx" ON "tickets" ("priority"); +CREATE INDEX IF NOT EXISTS "events_ticket_id_idx" ON "events" ("ticket_id"); +CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at"); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL UNIQUE, + "password_hash" TEXT, + "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), + "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), + "ldap_dn" TEXT, + "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS "users_username_idx" ON "users" ("username"); + +CREATE TABLE IF NOT EXISTS "app_settings" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ldap_sync_log" ( + "id" TEXT NOT NULL PRIMARY KEY, + "started_at" TEXT NOT NULL, + "finished_at" TEXT NOT NULL, + "trigger_type" TEXT NOT NULL CHECK ("trigger_type" IN ('manual', 'automatic')), + "status" TEXT NOT NULL CHECK ("status" IN ('success', 'error')), + "users_synced" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT +); + +CREATE INDEX IF NOT EXISTS "ldap_sync_log_finished_idx" ON "ldap_sync_log" ("finished_at" DESC); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..80b1752 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1293 @@ +{ + "name": "sds-crm", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sds-crm", + "version": "1.0.0", + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-session": "^1.19.0", + "ldapjs": "^3.0.7", + "xlsx": "^0.18.5" + }, + "engines": { + "node": ">=22.5.0" + } + }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "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/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "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/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "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.14.0", + "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/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/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "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-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/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "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/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "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.14.0", + "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-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "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/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "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/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/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-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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/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/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/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "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/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "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-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "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/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "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.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, + "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/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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/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.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/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/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/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "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/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b079063 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "sds-crm", + "private": true, + "version": "1.0.0", + "type": "module", + "engines": { + "node": ">=22.5.0" + }, + "scripts": { + "start": "node server/index.js", + "dev": "node --watch server/index.js", + "db:init": "node server/init-db.js", + "import:anlagen": "node scripts/import-anlagen-itt.mjs", + "import:anlagen:neu": "node scripts/import-anlagen-itt.mjs --replace" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "express-session": "^1.19.0", + "ldapjs": "^3.0.7", + "xlsx": "^0.18.5" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..4f861df --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,866 @@ +:root { + --bg: #0d1117; + --bg-card: #161b22; + --bg-raised: #1c2330; + --bg-hover: #212836; + --border: #30363d; + --border-hi: #444c56; + --text: #e6edf3; + --text-sub: #8b949e; + --text-muted:#6e7681; + --accent: #2f81f7; + --accent-hi: #58a6ff; + --green: #238636; + --green-fg: #3fb950; + --amber: #9e6a03; + --amber-fg: #d29922; + --red: #b91c1c; + --red-fg: #f85149; + + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 14px; + line-height: 1.55; + color: var(--text); + background: var(--bg); +} + +* { box-sizing: border-box; } + +body { margin: 0; } + +a { + color: var(--accent-hi); + text-decoration: none; +} +a:hover { text-decoration: underline; } + +/* ── Header ──────────────────────────────────────── */ +.header { + background: #010409; + border-bottom: 1px solid var(--border); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 2rem; + position: sticky; + top: 0; + z-index: 100; +} + +.header h1 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); + letter-spacing: 0.02em; + white-space: nowrap; +} + +.header nav { display: flex; gap: 0.25rem; } + +.header nav a { + color: var(--text-sub); + padding: 0.35rem 0.75rem; + border-radius: 6px; + font-size: 0.9rem; + transition: color 0.1s, background 0.1s; +} + +.header nav a:hover { + color: var(--text); + background: var(--bg-raised); + text-decoration: none; +} + +.header h1 a { + color: inherit; +} + +#main-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1rem; +} + +.nav-user { + font-size: 0.85rem; +} + +.btn-nav-logout { + padding: 0.3rem 0.65rem; + font-size: 0.85rem; +} + +.auth-panel { + max-width: 28rem; +} + +.row-inline { + flex-direction: row; + align-items: center; + gap: 0.5rem; +} + +.users-table .users-actions { + white-space: nowrap; +} + +.users-table .users-actions button { + margin-right: 0.35rem; + margin-bottom: 0.25rem; +} + +.options-page .options-section-title { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; + color: var(--text); + text-transform: none; + letter-spacing: normal; +} + +.options-page .options-section .muted code { + font-size: 0.85em; +} + +/* LDAP-Synchronisation (Referenz-Layout) */ +.ldap-section { + padding: 0; + overflow: hidden; +} + +.ldap-section-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 1.25rem; + margin: 0; + border: none; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); + color: var(--text); + font: inherit; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + text-align: left; +} + +.ldap-section-toggle:hover { + background: var(--bg-hover); +} + +.ldap-section-heading { + flex: 1; +} + +.ldap-chevron { + font-size: 0.75rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.ldap-section-body { + padding: 1rem 1.25rem 1.25rem; +} + +.ldap-section-body[hidden] { + display: none !important; +} + +.ldap-subtitle { + margin: 0 0 1rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-sub); +} + +.ldap-sync-check { + margin-bottom: 1rem; +} + +.form-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +@media (max-width: 720px) { + .form-grid-2 { + grid-template-columns: 1fr; + } +} + +.form-grid-ldap-attr { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem 1rem; + margin-bottom: 0.75rem; +} + +@media (max-width: 900px) { + .form-grid-ldap-attr { + grid-template-columns: 1fr; + } +} + +.options-page label.full-width { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.75rem; +} + +.ldap-filter-ta { + font-family: ui-monospace, monospace; + font-size: 0.82rem; + line-height: 1.45; + min-height: 5rem; +} + +.ldap-hint { + margin: -0.35rem 0 0.75rem; + font-size: 0.82rem; +} + +.options-actions { + display: flex; + justify-content: flex-start; +} + +.btn-config-save { + padding: 0.5rem 1.25rem; + font-weight: 600; +} + +/* LDAP Sync-Panel (Optionen) */ +.sync-panel .sync-actions { + margin-bottom: 0.75rem; +} + +.btn-ldap-sync-now { + padding: 0.5rem 1.1rem; + font-weight: 600; +} + +.sync-last-line { + margin: 0 0 1rem; + font-size: 0.92rem; +} + +.sync-log-title { + margin: 0 0 0.65rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-sub); + text-transform: none; + letter-spacing: normal; +} + +.sync-log-table-wrap { + overflow-x: auto; +} + +table.sync-log-table { + font-size: 0.88rem; +} + +table.sync-log-table th, +table.sync-log-table td { + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +table.sync-log-table th { + font-weight: 600; + color: var(--text-sub); + text-align: left; +} + +table.sync-log-table td.num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.sync-status-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; +} + +.sync-status-badge.sync-status-ok { + background: rgba(46, 160, 67, 0.25); + color: #3fb950; + border: 1px solid rgba(63, 185, 80, 0.45); +} + +.sync-status-badge.sync-status-err { + background: rgba(248, 81, 73, 0.15); + color: var(--red, #f85149); + border: 1px solid rgba(248, 81, 73, 0.35); +} + +.tv-device-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; +} + +.tv-device-row label { + flex: 1; + min-width: 12rem; +} + +.tv-conn-hint { + margin: 0.25rem 0 0; + font-size: 0.85rem; +} + +/* ── Layout ──────────────────────────────────────── */ +.main { + padding: 1.5rem 2rem; + max-width: 100%; +} + +/* ── Card ────────────────────────────────────────── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +/* ── Stack / Row ─────────────────────────────────── */ +.stack { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + align-items: flex-end; +} + +/* ── Typography ─────────────────────────────────── */ +h2 { margin: 0 0 0.5rem; font-size: 1.25rem; } +h3, h4 { margin: 0 0 0.5rem; font-size: 0.95rem; color: var(--text-sub); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } + +.muted { color: var(--text-muted); font-size: 0.9rem; } +.error { color: var(--red-fg); font-size: 0.9rem; } + +/* ── Forms ───────────────────────────────────────── */ +label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-sub); +} + +input, select, textarea, button { font: inherit; } + +input, select, textarea { + padding: 0.45rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + min-width: 180px; + transition: border-color 0.1s; +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); +} + +textarea { min-height: 72px; resize: vertical; } + +button { + padding: 0.45rem 0.9rem; + border-radius: 6px; + border: 1px solid var(--accent); + background: var(--accent); + color: #fff; + cursor: pointer; + font-weight: 500; + transition: opacity 0.12s; +} +button:hover { opacity: 0.85; } +button.secondary { + background: transparent; + color: var(--accent-hi); + border-color: var(--border-hi); +} +button.secondary:hover { background: var(--bg-hover); opacity: 1; } +button.danger { border-color: var(--red); background: var(--red); color: #fff; } +button:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Table (global) ──────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +th, td { + text-align: left; + padding: 0.55rem 0.6rem; + border-bottom: 1px solid var(--border); +} + +th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-sub); + font-weight: 600; +} + +/* ── Badge ───────────────────────────────────────── */ +.badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.03em; + background: var(--bg-raised); + color: var(--text-sub); + border: 1px solid var(--border-hi); +} + +/* ── Timeline ────────────────────────────────────── */ +.timeline { + border-left: 2px solid var(--border); + margin-left: 0.35rem; + padding-left: 1rem; +} +.timeline-item { margin-bottom: 1rem; } +.timeline-item time { font-size: 0.78rem; color: var(--text-muted); } + +/* ── Table-Wrap ──────────────────────────────────── */ +.table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* ── Extras / Anlagen ────────────────────────────── */ +.extras-table { font-size: 0.88rem; } +.extras-table th { + vertical-align: top; + font-weight: 600; + text-transform: none; + letter-spacing: normal; + color: var(--text-sub); + border-right: 1px solid var(--border); +} +/* Nur Key-Value-Extras: feste Schlüsselspalte (nicht Anlagenliste mit 3 Spalten) */ +.extras-table:not(.anlagen-voll) th { + width: 11rem; +} + +.extras-cell-input { + width: 100%; + min-width: 0; + box-sizing: border-box; + font: inherit; + font-size: 0.88rem; + padding: 0.35rem 0.45rem; + border-radius: 4px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.extras-table th .extras-cell-input { + font-weight: 500; + text-transform: none; + letter-spacing: normal; +} + +.machine-detail-card-title { + margin-top: 0; + font-size: 1rem; + font-weight: 600; +} + +.machine-detail-actions { + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} +/* Anlagenliste: Gruppe + Beschreibung schmal aber lesbar, „Wert“ der Rest. + Kein width:100% auf der letzten Zelle — das drückt Spalte 2 auf Mindestbreite. */ +.anlagen-voll { + width: 100%; + table-layout: fixed; +} +/* Spaltenbreiten über colgroup: 1+2 kompakt, 3 bekommt den Rest (kein width:100% auf Zellen) */ +.anlagen-voll col.anlagen-col-gruppe { + width: 10rem; +} +.anlagen-voll col.anlagen-col-beschr { + width: 22%; + min-width: 11rem; +} +.anlagen-voll col.anlagen-col-wert { + width: auto; +} +.anlagen-voll:not(.gruppiert) thead th { + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + vertical-align: bottom; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(1), +.anlagen-voll:not(.gruppiert) tbody td:nth-child(1) { + vertical-align: top; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.85rem; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(2), +.anlagen-voll:not(.gruppiert) tbody th:nth-child(2) { + vertical-align: top; + max-width: 28rem; + white-space: normal; + word-break: break-word; + hyphens: auto; +} +.anlagen-voll:not(.gruppiert) thead th:nth-child(3), +.anlagen-voll:not(.gruppiert) tbody td:nth-child(3) { + vertical-align: top; + word-break: break-word; +} + +/* Gruppierte Anlagenliste: Box pro Gruppe, Kopfzeile = Gruppenname */ +.anlagen-voll.gruppiert col.anlagen-col-beschr { + width: 22%; + min-width: 11rem; +} +.anlagen-voll.gruppiert col.anlagen-col-wert { + width: auto; +} +.anlagen-voll.gruppiert thead th { + text-transform: none; + letter-spacing: normal; + font-size: 0.75rem; + vertical-align: bottom; +} +.anlagen-voll.gruppiert thead th:first-child, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { + vertical-align: top; + max-width: 28rem; + white-space: normal; + word-break: break-word; + hyphens: auto; +} +.anlagen-voll.gruppiert thead th:last-child, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { + vertical-align: top; + word-break: break-word; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe-spacer td { + height: 0.85rem; + padding: 0 !important; + border: none !important; + background: transparent !important; +} +.anlagen-voll.gruppiert tr.anlagen-gruppe-kopf td { + font-weight: 600; + font-size: 0.8rem; + text-transform: none; + letter-spacing: 0.02em; + color: var(--text); + background: var(--bg-raised); + border: 1px solid var(--border-hi); + border-bottom: 1px solid var(--border); + padding: 0.5rem 0.65rem; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr.anlagen-gruppe-kopf td:first-child { + border-radius: 8px 8px 0 0; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th, +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td { + border-left: 1px solid var(--border-hi); + border-right: 1px solid var(--border-hi); +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th { + border-right: 1px solid var(--border); +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child th:first-child { + border-bottom: 1px solid var(--border-hi); + border-bottom-left-radius: 8px; +} +.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child td:last-child { + border-bottom: 1px solid var(--border-hi); + border-bottom-right-radius: 8px; +} + +.main:has(.machines-overview) { max-width: 100%; } + +code { + font-size: 0.85em; + background: var(--bg-raised); + color: var(--text-sub); + padding: 0.1em 0.35em; + border-radius: 4px; +} + +/* ═══════════════════════════════════════════════════ + Startseite — Offene Tickets +════════════════════════════════════════════════════ */ +.home-open-tickets { gap: 1rem; } + +.home-kpi-bar { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +.home-kpi-pills { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.kpi-pill .badge { + font-size: 0.8rem; + padding: 0.2rem 0.65rem; +} + +.home-open-tickets h2 { + font-size: 1.1rem; + color: var(--text); + font-weight: 600; + letter-spacing: 0; + text-transform: none; + margin: 0; +} + +.home-ticket-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.home-ticket-card { + padding: 0; + overflow: hidden; +} + +.home-ticket-top { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem 1rem; + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); +} + +.home-ticket-top-inner { + flex: 1; + min-width: 12rem; +} + +.home-ticket-titleline { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + font-size: 0.95rem; + margin-bottom: 0.35rem; +} + +.home-ticket-titleline > a { + font-weight: 600; +} + +.home-ticket-meta-row { + font-size: 0.8rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1rem; +} + +.home-ticket-open { + display: inline-block; + padding: 0.35rem 0.75rem; + border: 1px solid var(--accent); + border-radius: 6px; + font-size: 0.82rem; + font-weight: 500; + color: var(--accent-hi); + transition: background 0.1s; + flex-shrink: 0; + text-decoration: none; +} + +.home-ticket-open:hover { + background: rgba(47, 129, 247, 0.12); + text-decoration: none; +} + +.home-ticket-context { + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); +} + +.home-ticket-context-h { + margin: 0 0 0.4rem; + font-size: 0.82rem; +} + +.home-ticket-desc { + white-space: pre-wrap; + word-break: break-word; + color: var(--text-sub); + font-size: 0.88rem; + line-height: 1.55; + margin: 0; +} + +.home-ticket-machine { + margin: 0.65rem 0 0; + font-size: 0.85rem; + color: var(--text-sub); +} + +.home-ticket-events { + padding: 0.75rem 1.1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.home-ticket-no-events { + margin: 0; + font-size: 0.88rem; +} + +.home-event-box { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.65rem 0.85rem; + background: var(--bg); + font-size: 0.88rem; +} + +.home-event-box-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.45rem; +} + +.home-event-box-time { + font-size: 0.78rem; + color: var(--text-muted); +} + +.home-event-box-body :last-child { + margin-bottom: 0; +} + +/* Status badge variants */ +.badge-open { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } +.badge-waiting { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } +.badge-done { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); border-color: var(--border); } + +/* Priority badge variants */ +.badge-high { background: rgba(185, 28, 28, 0.18); color: var(--red-fg); border-color: rgba(248, 81, 73, 0.35); } +.badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); } +.badge-low { background: rgba(35, 134, 54, 0.1); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.2); } + +/* Ticket-Detail: Ereignisse als Tabelle (neueste zuerst) */ +.events-table { + font-size: 0.9rem; +} + +.events-table thead th { + background: var(--bg-raised); + white-space: nowrap; +} + +.events-table-time { + color: var(--text-sub); + font-variant-numeric: tabular-nums; + white-space: nowrap; + width: 1%; + vertical-align: top; +} + +.events-table-desc { + color: var(--text-sub); + vertical-align: top; + line-height: 1.5; +} + +.event-inhalt-block { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.event-inhalt-label { + margin: 0; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); + font-weight: 600; +} + +.event-inhalt-text { + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +.event-inhalt-meta { + margin: 0; + font-size: 0.88rem; +} + +.event-artnr { + font-size: 0.95em; +} + +.event-tv-placeholder { + font-size: 0.85em; +} + +.ev-field-group[hidden] { + display: none !important; +} + +.event-type-badge { + white-space: nowrap; +} + +.event-type-note { background: rgba(88, 166, 255, 0.12); color: var(--accent-hi); border-color: rgba(88, 166, 255, 0.35); } +.event-type-call { background: rgba(163, 113, 247, 0.14); color: #d2a8ff; border-color: rgba(163, 113, 247, 0.35); } +.event-type-remote { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); } +.event-type-part { background: rgba(210, 153, 34, 0.14); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.35); } +.event-type-system { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); border-color: var(--border-hi); } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f095543 --- /dev/null +++ b/public/index.html @@ -0,0 +1,17 @@ + + + + + + SDS CRM + + + +
+

SDS CRM

+ +
+
+ + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..95f83e0 --- /dev/null +++ b/public/js/api.js @@ -0,0 +1,114 @@ +async function parseError(res, text) { + try { + const j = JSON.parse(text); + if (j.message) { + const base = Array.isArray(j.message) ? j.message.join(', ') : j.message; + const parts = [base]; + if (j.detail && String(j.detail).trim()) { + parts.push(String(j.detail).trim()); + } + if (j.hint && String(j.hint).trim()) { + parts.push(String(j.hint).trim()); + } + return parts.join(' — '); + } + } catch { + /* ignore */ + } + return text || res.statusText; +} + +function redirectToLogin() { + if ( + !location.hash.startsWith('#/login') && + !location.hash.startsWith('#/bootstrap') + ) { + location.hash = '#/login'; + } +} + +/** Geschützte REST-API liegt unter /api (Root-URLs bleiben für die SPA frei). */ +function apiUrl(path) { + if (path.startsWith('/auth/')) return path; + return `/api${path.startsWith('/') ? path : `/${path}`}`; +} + +function onUnauthorized(path) { + if (path.startsWith('/auth/')) return; + redirectToLogin(); +} + +/** Wird bei 401 geworfen: Aufrufer sollen keine Fehlerseite rendern. */ +export function isAuthRedirectError(e) { + return Boolean(e && (e.authRedirect === true || e.name === 'AuthRedirect')); +} + +function authRedirectError() { + const err = new Error('SESSION'); + err.name = 'AuthRedirect'; + err.authRedirect = true; + return err; +} + +function parseJsonBody(text) { + if (!text) return null; + const trimmed = text.trim(); + if (trimmed.startsWith('<')) { + throw new Error( + 'Server lieferte HTML statt JSON (API-URL/Proxy prüfen oder Server neu starten).', + ); + } + try { + return JSON.parse(text); + } catch (e) { + throw new Error( + e && String(e.message || e).includes('JSON') + ? 'Ungültige Server-Antwort (kein JSON).' + : e.message || 'Ungültige Server-Antwort', + ); + } +} + +async function apiRequest(method, path, body) { + const url = apiUrl(path); + const opt = { method, credentials: 'include', headers: {} }; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + const res = await fetch(url, opt); + const text = await res.text(); + if (res.status === 401) { + if (path.startsWith('/auth/')) { + throw new Error((await parseError(res, text)) || 'Anmeldung fehlgeschlagen'); + } + onUnauthorized(path); + throw authRedirectError(); + } + if (!res.ok) throw new Error(await parseError(res, text)); + return parseJsonBody(text); +} + +export async function apiGet(path) { + return apiRequest('GET', path); +} + +export async function apiPost(path, body) { + return apiRequest('POST', path, body); +} + +export async function apiPut(path, body) { + return apiRequest('PUT', path, body); +} + +export async function apiDelete(path) { + return apiRequest('DELETE', path); +} + +/** Öffentlich: keine Session nötig, kein Redirect bei 401 */ +export async function authFetchStatus() { + const res = await fetch('/auth/status', { credentials: 'include' }); + const text = await res.text(); + if (!res.ok) throw new Error(await parseError(res, text)); + return parseJsonBody(text); +} diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..c437afc --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1502 @@ +import { + apiDelete, + apiGet, + apiPost, + apiPut, + authFetchStatus, + isAuthRedirectError, +} from './api.js'; + +/** Zwischenspeicher für GET /integrations/teamviewer/connections (Benutzer → Geräte). */ +let tvSessionsCache = null; + +const ticketStatusLabel = { + OPEN: 'Offen', + WAITING: 'Warte auf Rückmeldung', + DONE: 'Erledigt', +}; + +const ticketPriorityLabel = { + LOW: 'Niedrig', + MEDIUM: 'Mittel', + HIGH: 'Hoch', +}; + +const eventTypeLabel = { + NOTE: 'Notiz', + CALL: 'Anruf', + REMOTE: 'Remote', + PART: 'Ersatzteil benötigt', + SYSTEM: 'System', +}; + +const eventTypeBadgeClass = { + NOTE: 'event-type-note', + CALL: 'event-type-call', + REMOTE: 'event-type-remote', + PART: 'event-type-part', + SYSTEM: 'event-type-system', +}; + +/** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */ +function formatRemoteDurationDe(totalSec) { + if (totalSec == null || totalSec < 0) return ''; + const n = Math.floor(Number(totalSec)); + const m = Math.floor(n / 60); + const s = n % 60; + if (m === 0) return `${s} Sek.`; + if (s === 0) return `${m} Min.`; + return `${m} Min. ${s} Sek.`; +} + +/** HTML für die Inhaltsspalte (nur server-/formularbekannte Typen) */ +function eventInhaltHtml(ev) { + const t = ev.type; + if (t === 'CALL') { + let h = `

Beschreibung

${esc(ev.description)}
`; + if (ev.callbackNumber) { + h += `

Rückrufnummer: ${esc(ev.callbackNumber)}

`; + } + return `${h}
`; + } + if (t === 'REMOTE') { + let h = `

Beschreibung

${esc(ev.description)}
`; + if (ev.teamviewerId) { + h += `

Gerät-ID (TeamViewer): ${esc(ev.teamviewerId)}

`; + } + if (ev.remoteDurationSeconds != null) { + h += `

Remote-Dauer: ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}

`; + } + return `${h}
`; + } + if (t === 'PART') { + let h = `

Artikelnummer: ${esc(ev.articleNumber || '')}

`; + if (ev.description && String(ev.description).trim()) { + h += `

Bemerkung

${esc(ev.description)}
`; + } + return `${h}
`; + } + return `
${esc(ev.description)}
`; +} + +function fillTvDeviceSelect() { + const userSel = document.getElementById('tv-user-select'); + const devSel = document.getElementById('tv-conn-select'); + if (!devSel) return; + const ukey = userSel?.value ?? ''; + devSel.innerHTML = + ''; + if (!ukey || !tvSessionsCache) { + devSel.disabled = true; + return; + } + const u = (tvSessionsCache.users || []).find((x) => x.userKey === ukey); + const devices = u?.devices || []; + if (devices.length === 0) { + devSel.disabled = true; + return; + } + devSel.disabled = false; + devSel.innerHTML += + devices + .map( + (d) => + ``, + ) + .join(''); +} + +async function loadTeamViewerConnectionsIntoSelect() { + const userSel = document.getElementById('tv-user-select'); + const devSel = document.getElementById('tv-conn-select'); + const hint = document.getElementById('tv-conn-hint'); + if (!userSel || !devSel) return; + userSel.innerHTML = ''; + devSel.innerHTML = ''; + devSel.disabled = true; + if (hint) hint.textContent = ''; + try { + const data = await apiGet('/integrations/teamviewer/connections'); + tvSessionsCache = data; + const users = data.users || []; + userSel.innerHTML = + '' + + users + .map((u) => { + const label = + u.username && u.username !== '_unbekannt' + ? u.username + : u.userid + ? `Benutzer ${u.userid}` + : 'Unbekannt (Benutzer)'; + return ``; + }) + .join(''); + devSel.innerHTML = + ''; + devSel.disabled = true; + userSel.onchange = () => fillTvDeviceSelect(); + if (hint) { + const ndev = users.reduce((n, u) => n + (u.devices?.length || 0), 0); + if (users.length) { + hint.textContent = `${users.length} Benutzer, ${ndev} Gerät(e)/Session(s).`; + } else { + hint.textContent = + data.meta?.recordCount === 0 + ? 'Keine Verbindungen in den letzten 7 Tagen.' + : 'Keine gruppierten Einträge (TeamViewer-Antwort prüfen).'; + } + } + } catch (e) { + tvSessionsCache = null; + userSel.innerHTML = ''; + devSel.innerHTML = ''; + devSel.disabled = true; + if (hint) hint.textContent = e.message || 'Fehler'; + } +} + +function syncEventFormFieldGroups(form) { + const sel = form.querySelector('#ev-type-sel'); + if (!sel) return; + const v = sel.value; + form.querySelectorAll('.ev-field-group').forEach((el) => { + const show = el.getAttribute('data-ev-type') === v; + el.hidden = !show; + el.querySelectorAll('input, textarea').forEach((inp) => { + const name = inp.getAttribute('name'); + let req = false; + if (show) { + if (v === 'NOTE' && name === 'description_note') req = true; + if (v === 'CALL' && (name === 'description_call' || name === 'callbackNumber')) + req = true; + if (v === 'REMOTE' && name === 'description_remote') req = false; + if (v === 'PART' && name === 'articleNumber') req = true; + } + inp.required = req; + }); + }); + if (v === 'REMOTE') { + loadTeamViewerConnectionsIntoSelect(); + } +} + +function buildEventPostBody(ticketId, fd) { + const type = fd.get('type'); + const base = { ticketId, type }; + if (type === 'NOTE') { + return { ...base, description: fd.get('description_note') }; + } + if (type === 'CALL') { + return { + ...base, + description: fd.get('description_call'), + callbackNumber: fd.get('callbackNumber'), + }; + } + if (type === 'REMOTE') { + const body = { + ...base, + description: fd.get('description_remote'), + }; + const tv = fd.get('teamviewerDevice'); + if (tv && String(tv).trim()) body.teamviewerId = String(tv).trim(); + return body; + } + if (type === 'PART') { + return { + ...base, + articleNumber: fd.get('articleNumber'), + description: fd.get('description_part') || '', + }; + } + return base; +} + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function extrasName(m) { + const x = m?.extras; + if (!x || typeof x !== 'object') return ''; + return String(x.Name || '').trim(); +} + +function parseRoute() { + const pathOnly = (location.hash.slice(1) || '/home').split('?')[0]; + const raw = pathOnly.replace(/^\/+/, ''); + const parts = raw.split('/').filter(Boolean); + return { parts }; +} + +function formatDateTime(iso) { + try { + return new Date(iso).toLocaleString('de-DE'); + } catch { + return '—'; + } +} + +const appEl = document.getElementById('app'); + +function updateNav(st) { + const nav = document.getElementById('main-nav'); + if (!nav) return; + if (!st.loggedIn) { + nav.innerHTML = ''; + return; + } + const isAdmin = st.user?.role === 'admin'; + nav.innerHTML = ` + Start + Maschinen + Tickets + ${isAdmin ? 'OptionenBenutzer' : ''} + ${esc(st.user.username)} + `; + const btn = document.getElementById('btn-logout'); + if (btn) { + btn.onclick = async () => { + try { + await apiPost('/auth/logout', {}); + } catch { + /* ignore */ + } + updateNav({ loggedIn: false }); + location.hash = '#/login'; + }; + } +} + +async function viewBootstrap() { + appEl.innerHTML = ` +
+

Erster Administrator

+

Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).

+
+
+ + + +
+
+
`; + document.getElementById('form-boot').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPost('/auth/bootstrap', { + username: fd.get('username'), + password: fd.get('password'), + }); + location.hash = '#/home'; + }; +} + +async function viewLogin() { + appEl.innerHTML = ` +
+

Anmelden

+
+
+ + + +
+
+
`; + document.getElementById('form-login').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + await apiPost('/auth/login', { + username: fd.get('username'), + password: fd.get('password'), + }); + location.hash = '#/home'; + } catch (err) { + appEl.querySelector('.auth-err')?.remove(); + const p = document.createElement('p'); + p.className = 'error auth-err'; + p.textContent = err.message || 'Anmeldung fehlgeschlagen'; + document.getElementById('form-login').prepend(p); + } + }; +} + +function formatDeSyncDateTime(iso) { + if (!iso) return '—'; + try { + return new Date(iso).toLocaleString('de-DE', { + day: 'numeric', + month: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } catch { + return '—'; + } +} + +async function viewOptions() { + const data = await apiGet('/settings/integrations'); + let syncStatus = { lastSyncAt: null, entries: [] }; + try { + syncStatus = await apiGet('/ldap/sync-status'); + } catch { + /* z. B. ältere Server ohne Route */ + } + const ldap = data.ldap || {}; + const L = (k) => esc(ldap[k] ?? ''); + const userFilterVal = esc(ldap.userSearchFilter || ldap.userFilter || ''); + const syncMin = ldap.syncIntervalMinutes ?? 1440; + const tv = data.teamviewer || {}; + const bearer = esc(tv.bearerToken || tv.apiToken || ''); + const tvNotes = esc(tv.notes ?? tv.apiNotes ?? ''); + const lastSyncLabel = formatDeSyncDateTime(syncStatus.lastSyncAt); + const logEntries = Array.isArray(syncStatus.entries) ? syncStatus.entries : []; + const logRows = + logEntries.length > 0 + ? logEntries + .map( + (e) => ` + + ${esc(formatDeSyncDateTime(e.finishedAt))} + ${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'} + ${e.status === 'success' ? 'Erfolg' : 'Fehler'} + ${esc(String(e.usersSynced ?? 0))} + ${e.errorMessage ? esc(e.errorMessage) : '—'} + `, + ) + .join('') + : `Noch keine Einträge.`; + + appEl.innerHTML = ` +
+

← Start

+

Optionen

+

Integrationen für das CRM. Werte werden in der Datenbank gespeichert.

+
+
+ +
+

LDAP-Konfiguration

+ +
+ + + + +
+ +
+ + + +
+ +

0 = nur manuelle Synchronisation

+
+
+
+

Synchronisation

+
+ +
+

Letzte Synchronisation: ${esc(lastSyncLabel)}

+

Sync-Log (letzte 10 Einträge)

+
+ + + + + + + + + + + ${logRows} +
ZeitpunktTypStatusBenutzer synchronisiertFehlermeldung
+
+
+
+

TeamViewer

+

API-Aufrufe nutzen den Header Authorization: Bearer <token> (nur das Token ohne das Wort „Bearer“ eintragen).

+ + +
+
+ +
+
+
`; + + const ldapBody = document.getElementById('ldap-section-body'); + const ldapToggle = document.getElementById('ldap-toggle'); + const chev = ldapToggle.querySelector('.ldap-chevron'); + function setLdapOpen(open) { + ldapBody.hidden = !open; + ldapToggle.setAttribute('aria-expanded', String(open)); + chev.textContent = open ? '▲' : '▼'; + } + ldapToggle.onclick = () => setLdapOpen(ldapBody.hidden); + + document.getElementById('btn-ldap-sync-now').onclick = async () => { + const btn = document.getElementById('btn-ldap-sync-now'); + btn.disabled = true; + try { + await apiPost('/ldap/sync', {}); + await route(); + } catch (err) { + alert(err.message || String(err)); + } finally { + btn.disabled = false; + } + }; + + document.getElementById('form-opt').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPut('/settings/integrations', { + ldap: { + serverUrl: fd.get('ldap_serverUrl'), + bindDn: fd.get('ldap_bindDn'), + bindPassword: fd.get('ldap_bindPassword'), + searchBase: fd.get('ldap_searchBase'), + userSearchFilter: fd.get('ldap_userSearchFilter'), + usernameAttribute: fd.get('ldap_usernameAttribute'), + firstNameAttribute: fd.get('ldap_firstNameAttribute'), + lastNameAttribute: fd.get('ldap_lastNameAttribute'), + syncIntervalMinutes: fd.get('ldap_syncIntervalMinutes'), + syncEnabled: fd.get('ldap_syncEnabled') === 'on', + }, + teamviewer: { + bearerToken: fd.get('tv_bearerToken'), + notes: fd.get('tv_notes'), + }, + }); + route(); + }; +} + +async function viewUsers() { + const users = await apiGet('/users'); + appEl.innerHTML = ` +
+

← Start

+

Benutzer

+
+

Neuer Benutzer

+
+
+ + + + +
+
+
+
+ + + + + + + + + + + + ${users + .map( + (u) => ` + + + + + + + `, + ) + .join('')} + +
BenutzerRolleQuelleAktiv
${esc(u.username)}${u.role === 'admin' ? 'Admin' : 'Benutzer'}${u.source === 'ldap' ? 'LDAP' : 'Lokal'}${u.active ? 'Ja' : 'Nein'} + ${u.source === 'local' ? `` : ''} + + +
+
+
`; + + document.getElementById('form-new-user').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPost('/users', { + username: fd.get('username'), + password: fd.get('password'), + role: fd.get('role'), + }); + e.target.reset(); + route(); + }; + + appEl.querySelectorAll('.btn-pw').forEach((btn) => { + btn.onclick = async () => { + const uid = btn.getAttribute('data-id'); + const pw = window.prompt('Neues Passwort (min. 8 Zeichen):'); + if (!pw || pw.length < 8) return; + await apiPut(`/users/${uid}`, { password: pw }); + route(); + }; + }); + appEl.querySelectorAll('.btn-toggle').forEach((btn) => { + btn.onclick = async () => { + const uid = btn.getAttribute('data-id'); + const active = btn.getAttribute('data-active') === '1'; + await apiPut(`/users/${uid}`, { active: !active }); + route(); + }; + }); + appEl.querySelectorAll('.btn-del-user').forEach((btn) => { + btn.onclick = async () => { + if (!window.confirm('Benutzer wirklich löschen?')) return; + const uid = btn.getAttribute('data-id'); + await apiDelete(`/users/${uid}`); + route(); + }; + }); +} + +async function route() { + const { parts } = parseRoute(); + appEl.innerHTML = '

Lade …

'; + + let st; + try { + st = await authFetchStatus(); + } catch { + st = { needsBootstrap: false, loggedIn: false, user: null }; + } + updateNav(st); + + try { + if (st.needsBootstrap) { + if (parts[0] !== 'bootstrap') { + location.hash = '#/bootstrap'; + return; + } + await viewBootstrap(); + return; + } + if (!st.loggedIn) { + if (parts[0] !== 'login') { + location.hash = '#/login'; + return; + } + await viewLogin(); + return; + } + if (parts[0] === 'login' || parts[0] === 'bootstrap') { + location.hash = '#/home'; + return; + } + + const isAdmin = st.user?.role === 'admin'; + if (parts[0] === 'options' && parts.length === 1) { + if (!isAdmin) { + location.hash = '#/home'; + return; + } + await viewOptions(); + return; + } + if (parts[0] === 'users' && parts.length === 1) { + if (!isAdmin) { + location.hash = '#/home'; + return; + } + await viewUsers(); + return; + } + + if (parts[0] === 'home' && parts.length === 1) { + await viewHome(); + return; + } + if (parts[0] === 'machines' && parts.length === 1) { + await viewMachineList(); + return; + } + if (parts[0] === 'machines' && parts.length === 2) { + await viewMachineDetail(parts[1]); + return; + } + if (parts[0] === 'tickets' && parts.length === 1) { + await viewTicketList(); + return; + } + if (parts[0] === 'tickets' && parts.length === 2) { + await viewTicketDetail(parts[1]); + return; + } + location.hash = '#/home'; + } catch (e) { + if (isAuthRedirectError(e)) { + updateNav({ loggedIn: false, user: null, needsBootstrap: false }); + return; + } + appEl.innerHTML = `

${esc(e.message)}

`; + } +} + +/** Einheitlicher Platzhalter, wenn noch keine Gruppenzeile gesetzt war */ +const ANLAGEN_GRUPPE_PLACEHOLDER = '\u2014'; + +/** Baut das extras-Objekt aus dem Maschinen-Bearbeitungsformular (Anlagenliste). */ +function collectExtrasFromMachineForm(form, machine) { + const prev = + machine.extras && typeof machine.extras === 'object' + ? JSON.parse(JSON.stringify(machine.extras)) + : {}; + const beschr = prev._beschriftungZeile9; + const werte = prev._werteAlsListe; + const gruppe = prev._gruppeZeile7; + if ( + Array.isArray(beschr) && + Array.isArray(werte) && + beschr.length === werte.length + ) { + const n = beschr.length; + const newWerte = []; + for (let i = 0; i < n; i++) { + newWerte.push(String(form.elements[`extra_wert_${i}`]?.value ?? '')); + } + prev._werteAlsListe = newWerte; + return prev; + } + const out = {}; + for (const k of Object.keys(prev)) { + if (k.startsWith('_')) out[k] = prev[k]; + } + const kvKeys = Object.keys(prev).filter((k) => !k.startsWith('_')); + for (let i = 0; i < kvKeys.length; i++) { + const key = kvKeys[i]; + out[key] = String(form.elements[`extras_kv_val_${i}`]?.value ?? ''); + } + return out; +} + +function extrasTableHtml(extras, opts) { + const editable = opts && opts.editable === true; + if (!extras || typeof extras !== 'object') { + if (editable) { + return ` +
+

Anlagenliste (ITT)

+

Keine Anlagendaten gespeichert — nichts zu bearbeiten.

+
`; + } + return ''; + } + + const beschr = extras._beschriftungZeile9; + const gruppe = extras._gruppeZeile7; + const werte = extras._werteAlsListe; + + if ( + Array.isArray(beschr) && + Array.isArray(werte) && + beschr.length === werte.length + ) { + const g = + Array.isArray(gruppe) && gruppe.length === beschr.length + ? gruppe + : null; + + if (editable) { + const body = beschr + .map((label, i) => { + const v = werte[i]; + const grp = g ? String(g[i] ?? '') : ''; + return ` + ${esc(grp)} + ${esc(String(label || `Spalte ${i + 1}`))} + + `; + }) + .join(''); + return ` +
+

Anlagenliste (bearbeiten)

+

Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.

+
+ + + + + + + + + + + + + + ${body} +
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
+
+
`; + } + + /** Zeilen mit gleicher laufender Gruppe; Titelzeile nur bei Wechsel */ + if (g) { + const n = beschr.length; + const groupForRow = []; + let cur = ''; + for (let i = 0; i < n; i++) { + const raw = g[i] != null ? String(g[i]).trim() : ''; + if (raw) cur = raw; + groupForRow[i] = cur || ANLAGEN_GRUPPE_PLACEHOLDER; + } + const segments = []; + let segStart = 0; + for (let i = 1; i <= n; i++) { + if (i === n || groupForRow[i] !== groupForRow[i - 1]) { + segments.push({ from: segStart, to: i, key: groupForRow[segStart] }); + segStart = i; + } + } + + const gruppenTitel = (key) => + key === ANLAGEN_GRUPPE_PLACEHOLDER ? 'Sonstiges' : key; + + const tbodies = segments + .map((seg, idx) => { + const spacer = + idx > 0 + ? `` + : ''; + const titel = gruppenTitel(seg.key); + const kopf = `${esc(titel)}`; + const zeilen = []; + for (let i = seg.from; i < seg.to; i++) { + const v = werte[i]; + const show = v !== '' && v != null; + const label = String(beschr[i] || `Spalte ${i + 1}`); + zeilen.push( + `${esc(label)}${esc(String(v ?? ''))}`, + ); + } + return `${spacer}${kopf}${zeilen.join('')}`; + }) + .join(''); + + return ` +
+

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

+

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9. Zusammenhängende Zeilen mit gleicher Gruppe sind zusammengefasst.

+
+ + + + + + + + + + + + ${tbodies} +
Beschreibung (Z. 9)Wert
+
+
`; + } + + const body = beschr + .map((label, i) => { + const v = werte[i]; + const show = v !== '' && v != null; + const grp = ''; + return ` + ${esc(grp)} + ${esc(String(label || `Spalte ${i + 1}`))} + ${esc(String(v ?? ''))} + `; + }) + .join(''); + return ` +
+

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

+

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.

+
+ + + + + + + + + + + + + + ${body} +
Gruppe (Z. 7)Beschreibung (Z. 9)Wert
+
+
`; + } + + const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_')); + if (editable) { + if (kvEntries.length === 0) { + return ` +
+

Daten aus Anlagenliste (ITT)

+

Keine zusätzlichen Felder — nichts zu bearbeiten.

+
`; + } + const rows = kvEntries + .map( + ([k, v], i) => + ` + ${esc(k)} + + `, + ) + .join(''); + return ` +
+

Daten aus Anlagenliste (ITT)

+

Nur die Werte sind bearbeitbar; Feldnamen sind fest.

+
+ + ${rows} +
+
+
`; + } + + const rows = kvEntries + .filter(([, v]) => v !== '' && v != null) + .map( + ([k, v]) => + `${esc(k)}${esc(String(v))}`, + ) + .join(''); + if (!rows) return ''; + return ` +
+

Daten aus Anlagenliste (ITT)

+
+ + ${rows} +
+
+
`; +} + +async function viewMachineList() { + const machines = await apiGet('/machines'); + appEl.innerHTML = ` +
+

Maschinen (Anlagenliste)

+

${machines.length} Einträge · Import: npm run import:anlagen

+
+ +
+
+ + + + + + + + + + + + + + ${machines + .map((m) => { + const x = m.extras || {}; + return ` + + + + + + + + + `; + }) + .join('')} + +
Seriennr.TypKonzernName (Excel)StadtLandJahr
${esc(m.seriennummer)}${esc(m.typ)}${esc(x.Konzern || '')}${esc(x.Name || '')}${esc(x.Stadt || '')}${esc(x.Land || '')}${esc(x.Jahr || '')}
+
+
`; + + const inp = document.getElementById('machine-filter'); + const tbody = document.querySelector('#machine-table tbody'); + inp.addEventListener('input', () => { + const q = inp.value.toLowerCase().trim(); + tbody.querySelectorAll('tr').forEach((tr) => { + tr.hidden = !!(q && !tr.textContent.toLowerCase().includes(q)); + }); + }); +} + +async function viewMachineDetail(id) { + const m = await apiGet(`/machines/${id}`); + const ortName = extrasName(m); + + const renderView = () => { + appEl.innerHTML = ` +
+

← Alle Maschinen

+

${esc(m.name)}

+
+

Typ: ${esc(m.typ)}

+

Seriennummer: ${esc(m.seriennummer)}

+

Standort: ${esc(m.standort)}

+ ${ortName ? `

Name (Anlagenliste): ${esc(ortName)}

` : ''} +
+ + Tickets dieser Maschine + +
+
+ ${extrasTableHtml(m.extras)} +
`; + + document.getElementById('btn-m-del').onclick = async () => { + if (!confirm('Maschine wirklich löschen?')) return; + await apiDelete(`/machines/${id}`); + location.hash = '#/machines'; + }; + + document.getElementById('btn-m-edit').onclick = () => renderEdit(); + }; + + const renderEdit = () => { + appEl.innerHTML = ` +
+

← Alle Maschinen

+

${esc(m.name)}

+
+
+

Stammdaten

+
+ + +
+
+ + +
+
+ ${extrasTableHtml(m.extras, { editable: true })} +
+ + + Tickets dieser Maschine + +
+
+
`; + + const form = document.getElementById('form-m'); + form.onsubmit = async (e) => { + e.preventDefault(); + const body = { + name: form.elements.name.value, + typ: form.elements.typ.value, + seriennummer: form.elements.seriennummer.value, + standort: form.elements.standort.value, + extras: collectExtrasFromMachineForm(form, m), + }; + await apiPut(`/machines/${id}`, body); + route(); + }; + document.getElementById('m-cancel').onclick = () => renderView(); + document.getElementById('btn-m-del-edit').onclick = async () => { + if (!confirm('Maschine wirklich löschen?')) return; + await apiDelete(`/machines/${id}`); + location.hash = '#/machines'; + }; + }; + + renderView(); +} + +const statusBadgeClass = { OPEN: 'badge-open', WAITING: 'badge-waiting', DONE: 'badge-done' }; +const priorityBadgeClass = { HIGH: 'badge-high', MEDIUM: 'badge-medium', LOW: 'badge-low' }; + +async function viewHome() { + const tickets = await apiGet('/tickets?open=1'); + const eventsLists = + tickets.length === 0 + ? [] + : await Promise.all(tickets.map((t) => apiGet(`/tickets/${t.id}/events`))); + + const openCount = tickets.filter((t) => t.status === 'OPEN').length; + const waitingCount = tickets.filter((t) => t.status === 'WAITING').length; + + const listHtml = + tickets.length === 0 + ? '

Keine offenen Tickets.

' + : `
${tickets + .map((t, i) => { + const events = eventsLists[i] || []; + const mn = t.machine ? extrasName(t.machine) : ''; + const machineLabel = t.machine + ? `${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}` + : ''; + const standort = t.machine ? esc(t.machine.standort) : '—'; + const mBlock = t.machine + ? `

Maschine: ${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}  ·  Typ: ${esc(t.machine.typ)}

` + : ''; + const evChrono = [...events].reverse(); + const eventBoxes = + evChrono.length === 0 + ? '

Noch keine Ereignisse in der Historie.

' + : evChrono + .map( + (ev) => ` +
+
+ ${esc(eventTypeLabel[ev.type] || ev.type)} + +
+
${eventInhaltHtml(ev)}
+
`, + ) + .join(''); + return ` +
+
+
+
+ ${esc(t.title)} + ${esc(ticketStatusLabel[t.status])} + ${esc(ticketPriorityLabel[t.priority])} +
+
+ ${t.machine ? `Maschine: ${machineLabel}` : 'Keine Maschine'} + Standort: ${standort} + Erstellt: ${esc(formatDateTime(t.createdAt))} + Aktualisiert: ${esc(formatDateTime(t.updatedAt))} +
+
+ Ticket öffnen → +
+
+

Beschreibung

+

${esc(t.description)}

+ ${mBlock} +
+
${eventBoxes}
+
`; + }) + .join('')}
`; + + appEl.innerHTML = ` +
+
+

Offene Tickets

+
+ ${openCount} Offen + ${waitingCount} Wartend + gesamt: ${tickets.length} +
+
+ ${listHtml} +
`; +} + +function ticketListQuery() { + const q = new URLSearchParams(location.hash.split('?')[1] || ''); + const p = new URLSearchParams(); + if (q.get('status')) p.set('status', q.get('status')); + if (q.get('priority')) p.set('priority', q.get('priority')); + if (q.get('machineId')) p.set('machineId', q.get('machineId')); + const s = p.toString(); + return s ? `?${s}` : ''; +} + +async function viewTicketList() { + const qs = ticketListQuery(); + const hashParams = new URLSearchParams(location.hash.split('?')[1] || ''); + + const [tickets, allMachines] = await Promise.all([ + apiGet(`/tickets${qs}`), + apiGet('/machines'), + ]); + + const mid = hashParams.get('machineId') || ''; + + appEl.innerHTML = ` +
+

Tickets

+
+

Neues Ticket

+
+
+ +
+
+ + +
+ +
+
+
+

Filter

+
+
+ + + +
+ +
+
+
+ + + + ${tickets + .map( + (t) => ` + + + + + + `, + ) + .join('')} + +
TitelStatusPrioritätMaschine
${esc(t.title)}${esc(ticketStatusLabel[t.status])}${esc(ticketPriorityLabel[t.priority])}${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''}
+
+
`; + + const selFm = document.getElementById('sel-fm'); + if (mid) selFm.value = mid; + + document.getElementById('form-new-ticket').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPost('/tickets', { + machineId: fd.get('machineId'), + title: fd.get('title'), + description: fd.get('description'), + }); + e.target.reset(); + route(); + }; + + document.getElementById('form-filter').onsubmit = (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + const p = new URLSearchParams(); + if (fd.get('status')) p.set('status', fd.get('status')); + if (fd.get('priority')) p.set('priority', fd.get('priority')); + if (fd.get('machineId')) p.set('machineId', fd.get('machineId')); + const q = p.toString(); + location.hash = `#/tickets${q ? `?${q}` : ''}`; + }; +} + +async function viewTicketDetail(id) { + const [ticket, events] = await Promise.all([ + apiGet(`/tickets/${id}`), + apiGet(`/tickets/${id}/events`), + ]); + + const mn = ticket.machine ? extrasName(ticket.machine) : ''; + + appEl.innerHTML = ` +
+

← Zurück

+

${esc(ticket.title)}

+
+

Status: ${esc(ticketStatusLabel[ticket.status])}

+

Priorität: ${esc(ticketPriorityLabel[ticket.priority])}

+

Beschreibung:

+

${esc(ticket.description)}

+ ${ticket.machine ? `

Maschine: ${esc(ticket.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}

` : ''} + +
+

Historie

+
+

Event hinzufügen

+
+ +
+ +
+ + + + +
+
+
+ + + + + + + + + + ${ + events.length === 0 + ? `` + : events + .map( + (ev) => ` + + + + + `, + ) + .join('') + } + +
ZeitpunktArtInhalt
Noch keine Ereignisse.
${esc(formatDateTime(ev.createdAt))}${esc(eventTypeLabel[ev.type] || ev.type)}${eventInhaltHtml(ev)}
+
+

Weiteres Ticket für diese Maschine

+

Optional.

+
+
+
+ + +
+ +
+
+
`; + + const formEv = document.getElementById('form-ev'); + syncEventFormFieldGroups(formEv); + formEv.querySelector('#ev-type-sel').onchange = () => + syncEventFormFieldGroups(formEv); + document.getElementById('btn-tv-reload').onclick = () => + loadTeamViewerConnectionsIntoSelect(); + formEv.onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + let body = buildEventPostBody(id, fd); + if (body.type === 'REMOTE') { + const sel = document.getElementById('tv-conn-select'); + const opt = sel?.selectedOptions?.[0]; + if (opt?.value) { + body.teamviewerId = opt.value; + const sd = opt.getAttribute('data-start-date'); + const ed = opt.getAttribute('data-end-date'); + if (sd) body.teamviewerStartDate = sd; + if (ed) body.teamviewerEndDate = ed; + const dn = opt.getAttribute('data-devicename') || ''; + const u = String(fd.get('description_remote') ?? '').trim(); + if (dn) { + body.description = u + ? `${u}\n\nTeamViewer-Gerät: ${dn}` + : `TeamViewer-Gerät: ${dn}`; + } else if (u) { + body.description = u; + } else { + body.description = 'Remote-Session (TeamViewer)'; + } + } else { + body.description = String(body.description ?? '').trim(); + if (!body.description) { + formEv.insertAdjacentHTML( + 'afterbegin', + '

Beschreibung oder Gerät auswählen.

', + ); + setTimeout(() => { + document.querySelector('.tv-form-err')?.remove(); + }, 4000); + return; + } + } + } + await apiPost('/events', body); + e.target.reset(); + syncEventFormFieldGroups(formEv); + route(); + }; + + document.getElementById('form-t2').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPost('/tickets', { + machineId: ticket.machineId, + title: fd.get('title'), + description: fd.get('description'), + }); + e.target.reset(); + route(); + }; + + document.getElementById('btn-t-edit').onclick = () => { + document.getElementById('tick-view').innerHTML = ` +
+ + +
+ + +
+
+ + +
+
`; + document.getElementById('form-tu').onsubmit = async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + await apiPut(`/tickets/${id}`, Object.fromEntries(fd.entries())); + route(); + }; + document.getElementById('tu-cancel').onclick = () => route(); + }; +} + +window.addEventListener('unhandledrejection', (ev) => { + if (isAuthRedirectError(ev.reason)) { + ev.preventDefault(); + } +}); + +window.addEventListener('hashchange', route); +if (!location.hash) location.hash = '#/home'; +route(); diff --git a/scripts/import-anlagen-itt.mjs b/scripts/import-anlagen-itt.mjs new file mode 100644 index 0000000..d8ee3e6 --- /dev/null +++ b/scripts/import-anlagen-itt.mjs @@ -0,0 +1,156 @@ +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'url'; +import XLSX from 'xlsx'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const xlsxPath = path.join(root, 'Anlagenliste ITT.xlsx'); +const dbPath = process.env.SQLITE_PATH || path.join(root, 'data', 'crm.db'); + +/** Zeile 9 (1-basiert): Spaltenbeschriftung / Feldnamen */ +const ZEILE_BESCHREIBUNG = 9; +/** Zeile 7: Gruppierung / übergeordnete Rubrik pro Spalte (z. B. „Kunde“) */ +const ZEILE_GRUPPE = 7; +/** Nur Spalten 1–99 importieren; Spalten 100–180 entfallen */ +const MAX_SPALTEN = 99; + +if (!fs.existsSync(xlsxPath)) { + console.error('Datei nicht gefunden:', xlsxPath); + process.exit(1); +} + +function dedupeHeaders(raw) { + const count = {}; + return raw.map((h) => { + const base = String(h ?? '').trim() || 'Spalte'; + count[base] = (count[base] || 0) + 1; + return count[base] === 1 ? base : `${base}_${count[base]}`; + }); +} + +function padRow(arr, width) { + const a = Array.isArray(arr) ? [...arr] : []; + while (a.length < width) a.push(''); + return a.slice(0, width); +} + +const wb = XLSX.readFile(xlsxPath); +const sheet = + wb.Sheets.Anlagen || + wb.Sheets[wb.SheetNames.find((n) => /anlagen/i.test(n))] || + wb.Sheets[wb.SheetNames[0]]; + +const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }); +const beschreibIdx = ZEILE_BESCHREIBUNG - 1; +const gruppeIdx = ZEILE_GRUPPE - 1; + +let width = 0; +for (const r of [beschreibIdx, gruppeIdx]) { + if (aoa[r] && aoa[r].length > width) width = aoa[r].length; +} +if (sheet['!ref']) { + const range = XLSX.utils.decode_range(sheet['!ref']); + width = Math.max(width, range.e.c + 1); +} + +width = Math.min(width, MAX_SPALTEN); + +const rawRow9 = padRow(aoa[beschreibIdx] || [], width).map((h) => + String(h ?? '').trim(), +); +const rawRow7 = padRow(aoa[gruppeIdx] || [], width).map((h) => + String(h ?? '').trim(), +); +const headers = dedupeHeaders(rawRow9); + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); +const db = new DatabaseSync(dbPath); +db.exec('PRAGMA foreign_keys = ON'); + +const cols = db.prepare('PRAGMA table_info(machines)').all(); +if (!cols.some((c) => c.name === 'extras')) { + db.exec('ALTER TABLE machines ADD COLUMN extras TEXT'); +} + +const replaceAll = process.argv.includes('--replace'); +if (replaceAll) { + db.exec('DELETE FROM events'); + db.exec('DELETE FROM tickets'); + db.exec('DELETE FROM machines'); + console.log('Bestehende Maschinen/Tickets/Events gelöscht.'); +} + +const insertMachine = db.prepare( + `INSERT INTO machines (id, name, typ, seriennummer, standort, extras, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, +); + +let imported = 0; +let skipped = 0; + +db.exec('BEGIN'); +try { + for (let i = beschreibIdx + 1; i < aoa.length; i++) { + const row = aoa[i]; + if (!row || !row.length) continue; + const padded = padRow(row, width); + const sn = String(padded[0] ?? '').trim(); + if (!/^ITT#/i.test(sn)) continue; + + const dup = db + .prepare('SELECT id FROM machines WHERE seriennummer = ?') + .get(sn); + if (dup) { + skipped += 1; + continue; + } + + const rowObj = {}; + headers.forEach((h, j) => { + const v = padded[j]; + rowObj[h] = + v === '' || v === undefined || v === null ? '' : String(v).trim(); + }); + + const typ = rowObj.Typ || '—'; + const standort = + [rowObj.Stadt, rowObj.Land].filter(Boolean).join(', ') || '—'; + + const werteAlsListe = padded.map((v) => + v === '' || v === undefined || v === null ? '' : String(v).trim(), + ); + + const extrasObj = { + _beschriftungZeile9: rawRow9, + _gruppeZeile7: rawRow7, + _werteAlsListe: werteAlsListe, + ...rowObj, + }; + + const extrasJson = JSON.stringify(extrasObj); + const id = randomUUID(); + + insertMachine.run(id, sn, typ, sn, standort, extrasJson); + imported += 1; + } + db.exec('COMMIT'); +} catch (e) { + db.exec('ROLLBACK'); + throw e; +} + +db.close(); +console.log( + `Anlagenliste: ${imported} Maschinen importiert, ${skipped} übersprungen (Seriennr. schon vorhanden).`, +); +if (!replaceAll && skipped > 0 && imported === 0) { + console.log( + 'Hinweis: Für Neuimport: npm run import:anlagen -- --replace', + ); +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..bc882db --- /dev/null +++ b/server/db.js @@ -0,0 +1,163 @@ +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dbPath = + process.env.SQLITE_PATH || path.join(__dirname, '..', 'data', 'crm.db'); + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + +const db = new DatabaseSync(dbPath); +db.exec('PRAGMA foreign_keys = ON'); + +const machineCols = db.prepare('PRAGMA table_info(machines)').all(); +if (!machineCols.some((c) => c.name === 'extras')) { + db.exec('ALTER TABLE machines ADD COLUMN extras TEXT'); +} + +const hasCustomerId = machineCols.some((c) => c.name === 'customer_id'); +const tables = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ) + .all() + .map((r) => r.name); +const hasCustomersTable = tables.includes('customers'); + +const eventCols = db.prepare('PRAGMA table_info(events)').all(); +if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'remote_duration_seconds')) { + db.exec('ALTER TABLE events ADD COLUMN remote_duration_seconds INTEGER'); +} +const hasEventExtras = eventCols.some((c) => c.name === 'callback_number'); +if (eventCols.length > 0 && !hasEventExtras) { + db.exec('BEGIN'); + try { + db.exec(` + CREATE TABLE events_new ( + "id" TEXT NOT NULL PRIMARY KEY, + "ticket_id" TEXT NOT NULL, + "type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM')), + "description" TEXT NOT NULL, + "callback_number" TEXT, + "teamviewer_id" TEXT, + "article_number" TEXT, + "remote_duration_seconds" INTEGER, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, created_at) + SELECT + id, + ticket_id, + CASE WHEN type = 'WORK' THEN 'REMOTE' ELSE type END, + description, + NULL, + NULL, + NULL, + NULL, + created_at + FROM events; + DROP TABLE events; + ALTER TABLE events_new RENAME TO events; + `); + db.exec( + 'CREATE INDEX IF NOT EXISTS events_ticket_id_idx ON "events" ("ticket_id")', + ); + db.exec( + 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON "events" ("created_at")', + ); + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } +} + +if (hasCustomerId || hasCustomersTable) { + db.exec('BEGIN'); + try { + db.exec(` + CREATE TABLE machines_new ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "typ" TEXT NOT NULL, + "seriennummer" TEXT NOT NULL, + "standort" TEXT NOT NULL, + "extras" TEXT, + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO machines_new (id, name, typ, seriennummer, standort, extras, created_at, updated_at) + SELECT id, name, typ, seriennummer, standort, extras, created_at, updated_at FROM machines; + DROP TABLE machines; + ALTER TABLE machines_new RENAME TO machines; + `); + if (hasCustomersTable) { + db.exec('DROP TABLE customers'); + } + db.exec('COMMIT'); + } catch (e) { + db.exec('ROLLBACK'); + throw e; + } +} + +const tbl = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ) + .get(); +if (!tbl) { + db.exec(` + CREATE TABLE "users" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT NOT NULL UNIQUE, + "password_hash" TEXT, + "role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')), + "source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')), + "ldap_dn" TEXT, + "active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)), + "created_at" TEXT NOT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS users_username_idx ON "users" ("username"); + `); +} + +const tblSet = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'", + ) + .get(); +if (!tblSet) { + db.exec(` + CREATE TABLE "app_settings" ( + "key" TEXT NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL + ); + `); +} + +const ldapLogTbl = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='ldap_sync_log'", + ) + .get(); +if (!ldapLogTbl) { + db.exec(` + CREATE TABLE "ldap_sync_log" ( + "id" TEXT NOT NULL PRIMARY KEY, + "started_at" TEXT NOT NULL, + "finished_at" TEXT NOT NULL, + "trigger_type" TEXT NOT NULL CHECK ("trigger_type" IN ('manual', 'automatic')), + "status" TEXT NOT NULL CHECK ("status" IN ('success', 'error')), + "users_synced" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT + ); + CREATE INDEX ldap_sync_log_finished_idx ON "ldap_sync_log" ("finished_at" DESC); + `); +} + +export default db; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..6611abe --- /dev/null +++ b/server/index.js @@ -0,0 +1,776 @@ +import { randomUUID } from 'crypto'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import express from 'express'; +import session from 'express-session'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import db from './db.js'; +import { getSyncStatus, performLdapSync } from './ldap-sync.js'; +import { hashPassword, verifyPassword } from './password.js'; +import { + computeRemoteDurationSeconds, + registerTeamViewerRoutes, +} from './teamviewer.js'; + +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.set('trust proxy', 1); +app.use( + cors({ + origin: true, + credentials: true, + }), +); +app.use(express.json()); +app.use( + session({ + name: 'crm.sid', + secret: process.env.SESSION_SECRET || 'crm-dev-secret-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000, + }, + }), +); + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function badRequest(res, msg) { + return res.status(400).json({ message: msg }); +} + +function parseJsonField(v) { + if (v == null) return undefined; + if (typeof v === 'object') return v; + return JSON.parse(v); +} + +function mapMachine(r) { + const o = { + id: r.id, + name: r.name, + typ: r.typ, + seriennummer: r.seriennummer, + standort: r.standort, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + if (r.extras != null && String(r.extras).trim() !== '') { + try { + o.extras = + typeof r.extras === 'string' ? JSON.parse(r.extras) : r.extras; + } catch { + o.extras = null; + } + } + return o; +} + +function mapTicket(r) { + const machine_row = parseJsonField(r.machine_row); + const t = { + id: r.id, + machineId: r.machine_id, + title: r.title, + description: r.description, + status: r.status, + priority: r.priority, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + if (machine_row) { + t.machine = mapMachine(machine_row); + } + return t; +} + +function mapEvent(r) { + return { + id: r.id, + ticketId: r.ticket_id, + type: r.type, + description: r.description, + createdAt: r.created_at, + callbackNumber: r.callback_number ?? null, + teamviewerId: r.teamviewer_id ?? null, + articleNumber: r.article_number ?? null, + remoteDurationSeconds: + r.remote_duration_seconds != null ? r.remote_duration_seconds : null, + }; +} + +function mapPublicUser(r) { + return { + id: r.id, + username: r.username, + role: r.role, + active: Boolean(r.active), + source: r.source, + ldapDn: r.ldap_dn || null, + createdAt: r.created_at, + updatedAt: r.updated_at, + }; +} + +const ticketJoinSelect = ` + SELECT t.*, + json_object( + 'id', m.id, + 'name', m.name, + 'typ', m.typ, + 'seriennummer', m.seriennummer, + 'standort', m.standort, + 'extras', m.extras, + 'created_at', m.created_at, + 'updated_at', m.updated_at + ) AS machine_row + FROM tickets t + JOIN machines m ON m.id = t.machine_id`; + +const DEFAULT_INTEGRATIONS = { + ldap: { + serverUrl: '', + bindDn: '', + bindPassword: '', + searchBase: '', + userSearchFilter: '', + userFilter: '', + usernameAttribute: 'sAMAccountName', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + syncIntervalMinutes: 1440, + syncEnabled: false, + syncNotes: '', + }, + /* TeamViewer: Authorization: Bearer */ + teamviewer: { + bearerToken: '', + notes: '', + }, +}; + +function loadIntegrations() { + const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('integrations'); + const base = structuredClone(DEFAULT_INTEGRATIONS); + if (!row?.value) return base; + try { + const s = JSON.parse(row.value); + if (s.ldap && typeof s.ldap === 'object') Object.assign(base.ldap, s.ldap); + const ld = base.ldap; + if (!ld.userSearchFilter && ld.userFilter) ld.userSearchFilter = ld.userFilter; + if (s.teamviewer && typeof s.teamviewer === 'object') + Object.assign(base.teamviewer, s.teamviewer); + const tv = base.teamviewer; + if (!tv.bearerToken && tv.apiToken) tv.bearerToken = tv.apiToken; + if (tv.notes == null && tv.apiNotes) tv.notes = tv.apiNotes; + return base; + } catch { + return base; + } +} + +function saveIntegrations(obj) { + const json = JSON.stringify(obj); + db.prepare( + `INSERT INTO app_settings (key, value) VALUES ('integrations', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ).run(json); +} + +let ldapSyncTimer = null; + +function restartLdapSyncScheduler() { + if (ldapSyncTimer) { + clearInterval(ldapSyncTimer); + ldapSyncTimer = null; + } + const cfg = loadIntegrations().ldap; + if (!cfg.syncEnabled) return; + const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0); + if (m <= 0) return; + ldapSyncTimer = setInterval(() => { + performLdapSync(db, loadIntegrations, 'automatic').catch((err) => + console.error('LDAP auto-sync:', err), + ); + }, m * 60 * 1000); +} + +function requireAuth(req, res, next) { + if (!req.session?.userId) { + return res.status(401).json({ message: 'Nicht angemeldet' }); + } + const u = db + .prepare( + 'SELECT id, active FROM users WHERE id = ?', + ) + .get(req.session.userId); + if (!u || !u.active) { + req.session.destroy(() => {}); + return res.status(401).json({ message: 'Nicht angemeldet' }); + } + next(); +} + +function requireAdmin(req, res, next) { + if (req.session?.role !== 'admin') { + return res.status(403).json({ message: 'Administratorrechte erforderlich.' }); + } + next(); +} + +/** ——— Öffentlich: Auth ——— */ +app.get('/auth/status', (req, res) => { + const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; + const needsBootstrap = count === 0; + if (needsBootstrap) { + return res.json({ needsBootstrap: true, loggedIn: false, user: null }); + } + if (!req.session?.userId) { + return res.json({ needsBootstrap: false, loggedIn: false, user: null }); + } + const u = db + .prepare( + 'SELECT id, username, role, active FROM users WHERE id = ?', + ) + .get(req.session.userId); + if (!u || !u.active) { + req.session.destroy(() => {}); + return res.json({ needsBootstrap: false, loggedIn: false, user: null }); + } + res.json({ + needsBootstrap: false, + loggedIn: true, + user: { id: u.id, username: u.username, role: u.role }, + }); +}); + +app.post('/auth/bootstrap', async (req, res) => { + const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; + if (count > 0) { + return res.status(403).json({ message: 'Initialisierung nicht mehr möglich.' }); + } + const { username, password } = req.body || {}; + const un = String(username || '') + .trim() + .toLowerCase(); + if (!un || !password || password.length < 8) { + return badRequest(res, 'Benutzername und Passwort (min. 8 Zeichen) erforderlich.'); + } + const id = randomUUID(); + const ph = await hashPassword(password); + db.prepare( + `INSERT INTO users (id, username, password_hash, role, source, active, updated_at) + VALUES (?, ?, ?, 'admin', 'local', 1, datetime('now'))`, + ).run(id, un, ph); + req.session.userId = id; + req.session.role = 'admin'; + req.session.username = un; + res.status(201).json({ + user: { id, username: un, role: 'admin' }, + }); +}); + +app.post('/auth/login', async (req, res) => { + const { username, password } = req.body || {}; + const un = String(username || '') + .trim() + .toLowerCase(); + if (!un || !password) { + return badRequest(res, 'Benutzername und Passwort erforderlich.'); + } + const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un); + if (!u || !u.active) { + return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); + } + if (!u.password_hash) { + return res.status(401).json({ + message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.', + }); + } + const ok = await verifyPassword(password, u.password_hash); + if (!ok) return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); + req.session.userId = u.id; + req.session.role = u.role; + req.session.username = u.username; + res.json({ + user: { id: u.id, username: u.username, role: u.role }, + }); +}); + +app.post('/auth/logout', (req, res) => { + req.session.destroy((err) => { + if (err) return res.status(500).json({ message: 'Abmelden fehlgeschlagen.' }); + res.json({ ok: true }); + }); +}); + +/** ——— Geschützte API ——— */ +const api = express.Router(); +api.use(requireAuth); + +api.get('/machines', (_req, res) => { + const rows = db + .prepare('SELECT * FROM machines ORDER BY seriennummer ASC') + .all(); + res.json(rows.map(mapMachine)); +}); + +api.post('/machines', (req, res) => { + const { name, typ, seriennummer, standort } = req.body || {}; + if (!name || !typ || !seriennummer || !standort) { + return badRequest(res, 'Pflichtfelder fehlen.'); + } + const id = randomUUID(); + const row = db + .prepare( + `INSERT INTO machines (id, name, typ, seriennummer, standort, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) RETURNING *`, + ) + .get(id, name, typ, seriennummer, standort); + res.status(201).json(mapMachine(row)); +}); + +api.get('/machines/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const row = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); + if (!row) return res.status(404).json({ message: 'Nicht gefunden' }); + res.json(mapMachine(row)); +}); + +api.put('/machines/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + const b = req.body || {}; + const next = { + name: b.name ?? cur.name, + typ: b.typ ?? cur.typ, + seriennummer: b.seriennummer ?? cur.seriennummer, + standort: b.standort ?? cur.standort, + }; + let extrasJson = cur.extras; + if (Object.prototype.hasOwnProperty.call(b, 'extras')) { + if (b.extras === null || b.extras === '') { + extrasJson = null; + } else if (typeof b.extras === 'object' && b.extras !== null) { + try { + extrasJson = JSON.stringify(b.extras); + } catch { + return badRequest(res, 'extras ist kein gültiges JSON-Objekt.'); + } + } else if (typeof b.extras === 'string') { + try { + JSON.parse(b.extras); + extrasJson = b.extras; + } catch { + return badRequest(res, 'extras ist kein gültiger JSON-String.'); + } + } else { + return badRequest(res, 'extras hat ein ungültiges Format.'); + } + } + const row = db + .prepare( + `UPDATE machines SET name = ?, typ = ?, seriennummer = ?, standort = ?, extras = ?, updated_at = datetime('now') + WHERE id = ? RETURNING *`, + ) + .get(next.name, next.typ, next.seriennummer, next.standort, extrasJson, id); + res.json(mapMachine(row)); +}); + +api.delete('/machines/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + const tc = db + .prepare('SELECT COUNT(*) AS c FROM tickets WHERE machine_id = ?') + .get(id); + if (tc.c > 0) { + return res.status(409).json({ + message: + 'Maschine kann nicht gelöscht werden: Es existieren noch zugeordnete Tickets.', + }); + } + const row = db + .prepare('DELETE FROM machines WHERE id = ? RETURNING *') + .get(id); + res.json(mapMachine(row)); +}); + +api.get('/tickets', (req, res) => { + const { status, priority, machineId, open } = req.query; + const cond = ['1=1']; + const params = []; + const openFilter = open === '1' || open === 'true'; + if (openFilter) { + cond.push("t.status IN ('OPEN', 'WAITING')"); + } else if (status) { + cond.push('t.status = ?'); + params.push(status); + } + if (priority) { + cond.push('t.priority = ?'); + params.push(priority); + } + if (machineId) { + cond.push('t.machine_id = ?'); + params.push(machineId); + } + const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ORDER BY t.updated_at DESC`; + const rows = db.prepare(sql).all(...params); + res.json(rows.map(mapTicket)); +}); + +api.post('/tickets', (req, res) => { + const { machineId, title, description, status, priority } = req.body || {}; + if (!machineId || !title || !description) { + return badRequest(res, 'Pflichtfelder fehlen.'); + } + const m = db + .prepare('SELECT 1 AS ok FROM machines WHERE id = ?') + .get(machineId); + if (!m) return res.status(404).json({ message: 'Nicht gefunden' }); + const st = status || 'OPEN'; + const pr = priority || 'MEDIUM'; + const tid = randomUUID(); + db.prepare( + `INSERT INTO tickets (id, machine_id, title, description, status, priority, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, + ).run(tid, machineId, title, description, st, pr); + const full = db + .prepare(`${ticketJoinSelect} WHERE t.id = ?`) + .get(tid); + res.status(201).json(mapTicket(full)); +}); + +api.get('/tickets/:id/events', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const ex = db.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?').get(id); + if (!ex) return res.status(404).json({ message: 'Nicht gefunden' }); + const rows = db + .prepare( + 'SELECT * FROM events WHERE ticket_id = ? ORDER BY created_at DESC', + ) + .all(id); + res.json(rows.map(mapEvent)); +}); + +api.get('/tickets/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); + if (!row) return res.status(404).json({ message: 'Nicht gefunden' }); + res.json(mapTicket(row)); +}); + +api.put('/tickets/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + const b = req.body || {}; + const next = { + title: b.title ?? cur.title, + description: b.description ?? cur.description, + status: b.status ?? cur.status, + priority: b.priority ?? cur.priority, + }; + + const lines = []; + if (b.status !== undefined && b.status !== cur.status) { + lines.push(`Status: ${cur.status} → ${b.status}`); + } + if (b.priority !== undefined && b.priority !== cur.priority) { + lines.push(`Priorität: ${cur.priority} → ${b.priority}`); + } + if (b.title !== undefined && b.title !== cur.title) lines.push('Titel geändert'); + if (b.description !== undefined && b.description !== cur.description) { + lines.push('Beschreibung geändert'); + } + if (lines.length > 0) { + const eid = randomUUID(); + db.prepare( + `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds) + VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL)`, + ).run(eid, id, lines.join('; ')); + } + + db.prepare( + `UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(next.title, next.description, next.status, next.priority, id); + + const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); + res.json(mapTicket(row)); +}); + +const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']); + +registerTeamViewerRoutes(api, loadIntegrations); + +api.post('/events', (req, res) => { + const b = req.body || {}; + const ticketId = b.ticketId; + const type = b.type; + if (!ticketId || !type || !EVENT_TYPES_USER.has(type)) { + return badRequest(res, 'Pflichtfelder fehlen oder ungültiger Typ.'); + } + const t = db + .prepare('SELECT 1 AS ok FROM tickets WHERE id = ?') + .get(ticketId); + if (!t) return res.status(404).json({ message: 'Nicht gefunden' }); + + const desc = b.description != null ? String(b.description).trim() : ''; + const callbackNumber = + b.callbackNumber != null ? String(b.callbackNumber).trim() : ''; + const teamviewerId = + b.teamviewerId != null ? String(b.teamviewerId).trim() : ''; + const articleNumber = + b.articleNumber != null ? String(b.articleNumber).trim() : ''; + + let description = desc; + let cb = callbackNumber || null; + let tv = teamviewerId || null; + let art = articleNumber || null; + + if (type === 'NOTE') { + if (!description) return badRequest(res, 'Beschreibung fehlt.'); + cb = null; + tv = null; + art = null; + } else if (type === 'CALL') { + if (!description) return badRequest(res, 'Beschreibung fehlt.'); + if (!callbackNumber) return badRequest(res, 'Rückrufnummer fehlt.'); + tv = null; + art = null; + } else if (type === 'REMOTE') { + if (!description?.trim() && !tv) { + return badRequest(res, 'Beschreibung oder TeamViewer-Gerät erforderlich.'); + } + cb = null; + art = null; + } else if (type === 'PART') { + if (!articleNumber) return badRequest(res, 'Artikelnummer fehlt.'); + description = desc; + cb = null; + tv = null; + } + + let remoteDurationSeconds = null; + if (type === 'REMOTE') { + remoteDurationSeconds = computeRemoteDurationSeconds( + b.teamviewerStartDate, + b.teamviewerEndDate, + ); + } + + const eid = randomUUID(); + const row = db + .prepare( + `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`, + ) + .get( + eid, + ticketId, + type, + description, + cb, + tv, + art, + remoteDurationSeconds, + ); + res.status(201).json(mapEvent(row)); +}); + +app.use('/api', api); + +/** ——— Admin ——— */ +const admin = express.Router(); +admin.use(requireAuth, requireAdmin); + +admin.get('/users', (_req, res) => { + const rows = db + .prepare( + 'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC', + ) + .all(); + res.json(rows.map(mapPublicUser)); +}); + +admin.post('/users', async (req, res) => { + const { username, password, role } = req.body || {}; + const un = String(username || '') + .trim() + .toLowerCase(); + if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.'); + const r = role === 'admin' ? 'admin' : 'user'; + const id = randomUUID(); + const ph = await hashPassword(password); + try { + db.prepare( + `INSERT INTO users (id, username, password_hash, role, source, active, updated_at) + VALUES (?, ?, ?, ?, 'local', 1, datetime('now'))`, + ).run(id, un, ph, r); + } catch (e) { + if (String(e.message || e).includes('UNIQUE')) { + return res.status(409).json({ message: 'Benutzername bereits vergeben.' }); + } + throw e; + } + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + res.status(201).json(mapPublicUser(row)); +}); + +admin.put('/users/:id', async (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + const b = req.body || {}; + + if (b.password != null && String(b.password).length > 0) { + if (cur.source !== 'local') { + return badRequest(res, 'Passwort nur für lokale Benutzer änderbar.'); + } + const ph = await hashPassword(b.password); + db.prepare( + 'UPDATE users SET password_hash = ?, updated_at = datetime(\'now\') WHERE id = ?', + ).run(ph, id); + } + if (b.role !== undefined) { + if (b.role !== 'admin' && b.role !== 'user') { + return badRequest(res, 'Ungültige Rolle.'); + } + const admins = db + .prepare( + "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", + ) + .get().c; + if (cur.role === 'admin' && b.role === 'user' && admins <= 1) { + return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' }); + } + db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run( + b.role, + id, + ); + } + if (b.active !== undefined) { + const active = b.active ? 1 : 0; + const admins = db + .prepare( + "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", + ) + .get().c; + if (cur.role === 'admin' && cur.active && !active && admins <= 1) { + return res.status(400).json({ message: 'Letzter Administrator kann nicht deaktiviert werden.' }); + } + db.prepare('UPDATE users SET active = ?, updated_at = datetime(\'now\') WHERE id = ?').run( + active, + id, + ); + } + + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + res.json(mapPublicUser(row)); +}); + +admin.delete('/users/:id', (req, res) => { + const { id } = req.params; + if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); + if (id === req.session.userId) { + return res.status(400).json({ message: 'Eigenes Konto kann nicht gelöscht werden.' }); + } + const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); + if (cur.role === 'admin') { + const admins = db + .prepare( + "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", + ) + .get().c; + if (admins <= 1) { + return res.status(400).json({ message: 'Letzter Administrator kann nicht gelöscht werden.' }); + } + } + db.prepare('DELETE FROM users WHERE id = ?').run(id); + res.json({ ok: true }); +}); + +admin.get('/settings/integrations', (_req, res) => { + res.json(loadIntegrations()); +}); + +admin.put('/settings/integrations', (req, res) => { + const b = req.body || {}; + const cur = loadIntegrations(); + if (b.ldap && typeof b.ldap === 'object') { + const incoming = { ...b.ldap }; + if (incoming.bindPassword === '' || incoming.bindPassword == null) { + delete incoming.bindPassword; + } + if (incoming.syncIntervalMinutes != null) { + const n = Number(incoming.syncIntervalMinutes); + incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440; + } + Object.assign(cur.ldap, incoming); + if (b.ldap.userSearchFilter != null) { + cur.ldap.userFilter = String(b.ldap.userSearchFilter); + } + } + if (b.teamviewer && typeof b.teamviewer === 'object') { + const inc = { ...b.teamviewer }; + if (inc.bearerToken === '' || inc.bearerToken == null) { + delete inc.bearerToken; + } + Object.assign(cur.teamviewer, inc); + } + saveIntegrations(cur); + restartLdapSyncScheduler(); + res.json(loadIntegrations()); +}); + +admin.get('/ldap/sync-status', (_req, res) => { + res.json(getSyncStatus(db)); +}); + +admin.post('/ldap/sync', async (_req, res) => { + const r = await performLdapSync(db, loadIntegrations, 'manual'); + if (r.skipped) { + return res.status(409).json({ message: r.message }); + } + res.json(r); +}); + +app.use('/api', admin); + +/** Unbekannte /api/*-Routen: JSON 404 — verhindert SPA index.html (HTML) mit 200 bei falscher Route/alter Serverversion */ +app.use('/api', (req, res) => { + res.status(404).json({ message: 'API nicht gefunden' }); +}); + +app.use(express.static(path.join(__dirname, '..', 'public'))); + +/** SPA: Direktaufrufe wie /tickets ohne Hash liefern index.html (Client-Routing) */ +app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); +}); + +app.listen(PORT, () => { + restartLdapSyncScheduler(); + console.log(`CRM-Server http://localhost:${PORT}`); +}); diff --git a/server/init-db.js b/server/init-db.js new file mode 100644 index 0000000..7e4ce3b --- /dev/null +++ b/server/init-db.js @@ -0,0 +1,29 @@ +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const sqlPath = path.join(__dirname, '..', 'database', 'init.sql'); +const dbPath = + process.env.SQLITE_PATH || path.join(__dirname, '..', 'data', 'crm.db'); + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + +const sql = fs.readFileSync(sqlPath, 'utf8'); + +const db = new DatabaseSync(dbPath); +db.exec('PRAGMA foreign_keys = ON'); + +try { + db.exec(sql); + console.log('Datenbank-Schema ist bereit.'); +} catch (e) { + console.error(e); + process.exit(1); +} finally { + db.close(); +} diff --git a/server/ldap-sync.js b/server/ldap-sync.js new file mode 100644 index 0000000..f358f6b --- /dev/null +++ b/server/ldap-sync.js @@ -0,0 +1,209 @@ +import { randomUUID } from 'crypto'; +import ldap from 'ldapjs'; + +let syncRunning = false; + +function getAttr(entry, name) { + const want = String(name || '').toLowerCase(); + for (const attr of entry.attributes) { + if (attr.type.toLowerCase() === want) { + const v = attr.values[0]; + return v != null ? String(v).trim() : ''; + } + } + return ''; +} + +function searchAsync(client, base, options) { + return new Promise((resolve, reject) => { + const results = []; + client.search(base, options, (err, res) => { + if (err) return reject(err); + res.on('searchEntry', (entry) => results.push(entry)); + res.on('error', reject); + res.on('end', () => resolve(results)); + }); + }); +} + +function bindAsync(client, dn, password) { + return new Promise((resolve, reject) => { + client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve())); + }); +} + +function unbindAsync(client) { + return new Promise((resolve) => { + try { + client.unbind(() => resolve()); + } catch { + resolve(); + } + }); +} + +function insertLog(db, row) { + db.prepare( + `INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + row.id, + row.startedAt, + row.finishedAt, + row.triggerType, + row.status, + row.usersSynced, + row.errorMessage, + ); +} + +/** + * @param {import('node:sqlite').DatabaseSync} db + * @param {() => object} loadIntegrations + * @param {'manual' | 'automatic'} trigger + */ +export async function performLdapSync(db, loadIntegrations, trigger) { + if (syncRunning) { + return { skipped: true, message: 'Synchronisation läuft bereits.' }; + } + syncRunning = true; + const logId = randomUUID(); + const startedAt = new Date().toISOString(); + let usersSynced = 0; + let errorMessage = null; + let client = null; + + try { + const config = loadIntegrations().ldap || {}; + const serverUrl = String(config.serverUrl || '').trim(); + const searchBase = String(config.searchBase || '').trim(); + const filter = String( + config.userSearchFilter || config.userFilter || '', + ).trim(); + const usernameAttr = String( + config.usernameAttribute || 'sAMAccountName', + ).trim(); + + if (!serverUrl) throw new Error('LDAP-Server URL fehlt.'); + if (!searchBase) throw new Error('Base DN fehlt.'); + if (!filter) throw new Error('User Search Filter fehlt.'); + + client = ldap.createClient({ + url: serverUrl, + timeout: 120000, + connectTimeout: 20000, + reconnect: false, + }); + + client.on('error', () => {}); + + const bindDn = String(config.bindDn || '').trim(); + const bindPassword = + config.bindPassword != null ? String(config.bindPassword) : ''; + + await bindAsync(client, bindDn, bindPassword); + + const entries = await searchAsync(client, searchBase, { + scope: 'sub', + filter, + attributes: [usernameAttr], + }); + + await unbindAsync(client); + client = null; + + db.exec('BEGIN'); + try { + let n = 0; + for (const entry of entries) { + let dn; + try { + dn = entry.objectName.toString(); + } catch { + continue; + } + const un = getAttr(entry, usernameAttr).toLowerCase().trim(); + if (!un) continue; + + const existing = db + .prepare('SELECT id, source FROM users WHERE username = ?') + .get(un); + if (existing) { + if (existing.source === 'local') continue; + db.prepare( + `UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`, + ).run(dn, existing.id); + n += 1; + } else { + const id = randomUUID(); + db.prepare( + `INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at) + VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`, + ).run(id, un, dn); + n += 1; + } + } + db.exec('COMMIT'); + usersSynced = n; + } catch (e) { + try { + db.exec('ROLLBACK'); + } catch { + /* ignore */ + } + throw e; + } + } catch (e) { + errorMessage = e && e.message ? String(e.message) : String(e); + if (client) { + try { + await unbindAsync(client); + } catch { + /* ignore */ + } + client = null; + } + } finally { + try { + const finishedAt = new Date().toISOString(); + const status = errorMessage ? 'error' : 'success'; + insertLog(db, { + id: logId, + startedAt, + finishedAt, + triggerType: trigger, + status, + usersSynced: errorMessage ? 0 : usersSynced, + errorMessage, + }); + } finally { + syncRunning = false; + } + } + + if (errorMessage) { + return { ok: false, usersSynced: 0, error: errorMessage }; + } + return { ok: true, usersSynced }; +} + +/** + * @param {import('node:sqlite').DatabaseSync} db + */ +export function getSyncStatus(db) { + const last = db + .prepare( + `SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`, + ) + .get(); + const entries = db + .prepare( + `SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage + FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`, + ) + .all(); + return { + lastSyncAt: last?.finished_at ?? null, + entries, + }; +} diff --git a/server/password.js b/server/password.js new file mode 100644 index 0000000..e7b82d2 --- /dev/null +++ b/server/password.js @@ -0,0 +1,12 @@ +import bcrypt from 'bcrypt'; + +const ROUNDS = 10; + +export async function hashPassword(plain) { + return bcrypt.hash(plain, ROUNDS); +} + +export async function verifyPassword(plain, hash) { + if (!hash || typeof hash !== 'string') return false; + return bcrypt.compare(plain, hash); +} diff --git a/server/teamviewer.js b/server/teamviewer.js new file mode 100644 index 0000000..088412c --- /dev/null +++ b/server/teamviewer.js @@ -0,0 +1,294 @@ +/** + * TeamViewer Web API: Connection Reports (/api/v1/reports/connections) + */ + +const CONNECTIONS_URL = + 'https://webapi.teamviewer.com/api/v1/reports/connections'; + +const FETCH_HEADERS_BASE = { + Accept: 'application/json', + 'User-Agent': 'SDS-CRM/1.0 (Node.js; TeamViewer reports/connections)', +}; + +/** + * TeamViewer akzeptiert ISO-8601-UTC ohne Millisekunden, z. B. 2019-01-31T19:20:30Z. + * JavaScripts toISOString() liefert .sssZ — das führt zu HTTP 400 „from_date is not valid“. + */ +export function formatTeamViewerDateParam(date) { + return new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** Dauer aus TeamViewer-Feldern `start_date` / `end_date` (ISO-Strings, ggf. leer). */ +export function computeRemoteDurationSeconds(startIso, endIso) { + const ts = startIso != null ? String(startIso).trim() : ''; + const te = endIso != null ? String(endIso).trim() : ''; + if (!ts || !te) return null; + const s = new Date(ts); + const e = new Date(te); + if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime()) || e < s) { + return null; + } + return Math.max(0, Math.round((e - s) / 1000)); +} + +/** + * Gruppiert nach Benutzer (userid, sonst username), pro Benutzer Geräte mit + * jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid. + */ +/** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */ +function pickUserIdentity(rec) { + const uid = String( + rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '', + ).trim(); + const un = String( + rec.username ?? + rec.user_name ?? + rec.userName ?? + rec.UserName ?? + '', + ).trim(); + return { uid, un }; +} + +function buildSessionsByUser(allRecords) { + const byUser = new Map(); + for (const rec of allRecords) { + const { uid, un } = pickUserIdentity(rec); + const userKey = uid || un || '_unbekannt'; + if (!byUser.has(userKey)) { + byUser.set(userKey, { + userid: uid || null, + username: un || userKey, + records: [], + }); + } + byUser.get(userKey).records.push(rec); + } + + const users = []; + for (const [userKey, { userid, username, records }] of byUser) { + const byDev = new Map(); + for (const rec of records) { + const did = String( + rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '', + ).trim(); + const dn = String( + rec.devicename ?? rec.device_name ?? rec.deviceName ?? '', + ).trim(); + const dkey = did || dn; + if (!dkey) continue; + if (!byDev.has(dkey)) byDev.set(dkey, []); + byDev.get(dkey).push(rec); + } + const devices = []; + for (const [, recs] of byDev) { + recs.sort((a, b) => { + const eb = + (b.end_date ?? b.endDate ?? b.EndDate) + ? new Date(b.end_date ?? b.endDate ?? b.EndDate).getTime() + : 0; + const ea = + (a.end_date ?? a.endDate ?? a.EndDate) + ? new Date(a.end_date ?? a.endDate ?? a.EndDate).getTime() + : 0; + return eb - ea; + }); + const rec = recs[0]; + const did = String( + rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '', + ).trim(); + const dn = String( + rec.devicename ?? rec.device_name ?? rec.deviceName ?? '', + ).trim(); + const label = dn || did; + const idForCrm = did || dn; + const startRaw = rec.start_date ?? rec.startDate ?? rec.StartDate; + const endRaw = rec.end_date ?? rec.endDate ?? rec.EndDate; + const startDate = startRaw ? String(startRaw).trim() : ''; + const endDate = endRaw ? String(endRaw).trim() : ''; + devices.push({ + deviceid: idForCrm, + label, + startDate: startDate || null, + endDate: endDate || null, + durationSeconds: computeRemoteDurationSeconds(startDate, endDate), + }); + } + devices.sort((a, b) => + a.label.localeCompare(b.label, 'de', { sensitivity: 'base' }), + ); + users.push({ + userKey, + userid, + username, + devices, + }); + } + users.sort((a, b) => + a.username.localeCompare(b.username, 'de', { sensitivity: 'base' }), + ); + return users; +} + +function upstreamError(res, httpStatus, raw) { + const text = String(raw ?? '').trim(); + if (text.startsWith('<')) { + return res.status(502).json({ + message: + 'TeamViewer-Web-API lieferte HTML statt JSON. Typisch: Script-Token ungültig/abgelaufen, falsche Berechtigungen (Verbindungsberichte), oder Dienst kurzzeitig nicht erreichbar.', + hint: 'TeamViewer Management Console → Apps → Script-Token: Zugriff auf Reporting / Connection Reports prüfen.', + devices: [], + users: [], + }); + } + let detail = text.slice(0, 600); + try { + const j = JSON.parse(text); + const msg = + (typeof j.error_description === 'string' && j.error_description) || + (typeof j.error === 'string' && j.error) || + (typeof j.message === 'string' && j.message); + if (msg) detail = String(msg); + } catch { + /* Rohtext */ + } + return res.status(502).json({ + message: `TeamViewer-API meldet HTTP ${httpStatus}.`, + detail, + devices: [], + users: [], + }); +} + +function bodyNotJson(res, raw) { + const text = String(raw ?? '').trim(); + if (text.startsWith('<')) { + return upstreamError(res, 200, raw); + } + return res.status(502).json({ + message: 'TeamViewer: Antwort ist kein gültiges JSON.', + detail: text.slice(0, 400), + devices: [], + users: [], + }); +} + +/** TeamViewer-Antworten: `records` (Reports) oder andere Schlüssel / verschachtelt. */ +function extractReportRecords(data) { + if (!data || typeof data !== 'object') return []; + if (Array.isArray(data)) return data; + const candidates = [ + data.records, + data.Records, + data.connections, + data.Connections, + data.items, + data.Items, + data.values, + data.data?.records, + data.data?.connections, + data.report?.records, + ]; + let fallbackEmpty = null; + for (const c of candidates) { + if (Array.isArray(c)) { + if (c.length > 0) return c; + if (fallbackEmpty === null) fallbackEmpty = c; + } + } + return fallbackEmpty ?? []; +} + +function hasNextPage(data) { + const next = data?.next_offset ?? data?.nextOffset ?? data?.NextOffset; + if (next == null) return false; + const s = String(next).trim(); + return s.length > 0; +} + +/** @param {import('express').Router} api @param {() => object} loadIntegrations */ +export function registerTeamViewerRoutes(api, loadIntegrations) { + api.get('/integrations/teamviewer/connections', async (_req, res) => { + const integ = loadIntegrations(); + const token = String( + integ.teamviewer?.bearerToken || integ.teamviewer?.apiToken || '', + ).trim(); + if (!token) { + return res.status(400).json({ + message: + 'TeamViewer Bearer-Token in den Optionen hinterlegen (Abschnitt TeamViewer).', + devices: [], + users: [], + }); + } + const now = new Date(); + const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const fromStr = formatTeamViewerDateParam(from); + const toStr = formatTeamViewerDateParam(now); + + try { + const allRecords = []; + let offset; + let pagesFetched = 0; + for (let page = 0; page < 25; page++) { + pagesFetched += 1; + const u = new URL(CONNECTIONS_URL); + u.searchParams.set('from_date', fromStr); + u.searchParams.set('to_date', toStr); + if (offset != null && offset !== '') { + u.searchParams.set('offset', String(offset)); + } + const r = await fetch(u, { + headers: { + ...FETCH_HEADERS_BASE, + Authorization: `Bearer ${token}`, + }, + }); + const raw = await r.text(); + if (!r.ok) { + console.error( + '[TeamViewer API] upstream HTTP', + r.status, + String(raw ?? '').slice(0, 500), + ); + return upstreamError(res, r.status, raw); + } + let data; + try { + data = JSON.parse(raw); + } catch { + return bodyNotJson(res, raw); + } + const recs = extractReportRecords(data); + for (const rec of recs) { + allRecords.push(rec); + } + /* + * Wichtig: nicht abbrechen, nur weil eine Seite 0 Zeilen hat — TeamViewer liefert + * manchmal leere erste Seiten, setzt aber weiter next_offset (vgl. records_remaining). + * Früher: if (!next || recs.length === 0) break → alle Daten verloren. + */ + if (!hasNextPage(data)) break; + offset = data.next_offset ?? data.nextOffset ?? data.NextOffset; + } + + const users = buildSessionsByUser(allRecords); + res.json({ + users, + devices: [], + meta: { + recordCount: allRecords.length, + pagesFetched, + source: 'reports/connections', + }, + }); + } catch (e) { + console.error('[TeamViewer API] interner Fehler', e); + res.status(500).json({ + message: 'TeamViewer-Verbindungen konnten nicht geladen werden.', + devices: [], + users: [], + }); + } + }); +}