Add account management and LDAP login
Admins can now create local agent/admin accounts and configure LDAP directly from the UI (Настройки → Аккаунты / LDAP), with login trying LDAP first and falling back to the local password. This is the first place users.role is actually enforced (requireAdminSession). Fixed a bug in the generated 0005 migration: the INSERT into __new_users selected auth_source from the old users table, which doesn't have that column yet — caused drizzle-kit migrate to fail silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
8121d8aa51
commit
cb12711e91
23 files changed
+2379
-13
No files matched your search
Generated
+221
@@ -13,6 +13,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
"imapflow": "^1.5.0",
|
"imapflow": "^1.5.0",
|
||||||
|
"ldapjs": "^3.0.7",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/ldapjs": "^3.0.6",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
@@ -1824,6 +1826,112 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"dependencies": {
|
||||||
|
"@ldapjs/asn1": "2.0.0",
|
||||||
|
"@ldapjs/protocol": "^1.2.1",
|
||||||
|
"process-warning": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ldapjs/attribute/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=="
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"dependencies": {
|
||||||
|
"@ldapjs/asn1": "2.0.0",
|
||||||
|
"process-warning": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ldapjs/dn/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=="
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"dependencies": {
|
||||||
|
"@ldapjs/asn1": "2.0.0",
|
||||||
|
"@ldapjs/protocol": "^1.2.1",
|
||||||
|
"process-warning": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@ldapjs/filter/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=="
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"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/messages/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=="
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -2701,6 +2809,15 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ldapjs": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mailparser": {
|
"node_modules/@types/mailparser": {
|
||||||
"version": "3.4.6",
|
"version": "3.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
|
||||||
@@ -3468,6 +3585,11 @@
|
|||||||
"node": ">=6.5"
|
"node": ">=6.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -3702,6 +3824,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -3767,6 +3897,17 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"precond": "0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -4102,6 +4243,11 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"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=="
|
||||||
|
},
|
||||||
"node_modules/cross-env": {
|
"node_modules/cross-env": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||||
@@ -5243,6 +5389,14 @@
|
|||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -6435,6 +6589,28 @@
|
|||||||
"node": ">=0.10"
|
"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",
|
||||||
|
"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/leac": {
|
"node_modules/leac": {
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||||
@@ -7523,6 +7699,14 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -9451,6 +9635,43 @@
|
|||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"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==",
|
||||||
|
"dependencies": {
|
||||||
|
"assert-plus": "^1.0.0",
|
||||||
|
"core-util-is": "1.0.2",
|
||||||
|
"extsprintf": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^12.4.7",
|
"framer-motion": "^12.4.7",
|
||||||
"imapflow": "^1.5.0",
|
"imapflow": "^1.5.0",
|
||||||
|
"ldapjs": "^3.0.7",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/ldapjs": "^3.0.6",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/nodemailer": "^8.0.1",
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
||||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||||
<NotificationToggle />
|
<NotificationToggle />
|
||||||
<SettingsMenu />
|
<SettingsMenu isAdmin={session.user.role === "admin"} />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,24 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Settings, Send, Mail, Code2, MessageSquareText, Tags, UserCog } from "lucide-react";
|
import { Settings, Send, Mail, Code2, MessageSquareText, Tags, UserCog, Users, KeyRound } from "lucide-react";
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ href: "/settings/telegram", label: "Telegram", icon: Send },
|
{ href: "/settings/telegram", label: "Telegram", icon: Send, adminOnly: false },
|
||||||
{ href: "/settings/email", label: "Почта", icon: Mail },
|
{ href: "/settings/email", label: "Почта", icon: Mail, adminOnly: false },
|
||||||
{ href: "/settings/widget", label: "Виджет", icon: Code2 },
|
{ href: "/settings/widget", label: "Виджет", icon: Code2, adminOnly: false },
|
||||||
{ href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText },
|
{ href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText, adminOnly: false },
|
||||||
{ href: "/settings/tags", label: "Теги", icon: Tags },
|
{ href: "/settings/tags", label: "Теги", icon: Tags, adminOnly: false },
|
||||||
{ href: "/settings/account", label: "Аккаунт", icon: UserCog },
|
{ href: "/settings/accounts", label: "Аккаунты", icon: Users, adminOnly: true },
|
||||||
|
{ href: "/settings/ldap", label: "LDAP", icon: KeyRound, adminOnly: true },
|
||||||
|
{ href: "/settings/account", label: "Аккаунт", icon: UserCog, adminOnly: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function SettingsMenu() {
|
export function SettingsMenu({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const visibleItems = items.filter((item) => !item.adminOnly || isAdmin);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onClickOutside(e: MouseEvent) {
|
function onClickOutside(e: MouseEvent) {
|
||||||
@@ -29,7 +32,7 @@ export function SettingsMenu() {
|
|||||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isActive = items.some((item) => pathname.startsWith(item.href));
|
const isActive = visibleItems.some((item) => pathname.startsWith(item.href));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className="relative">
|
<div ref={rootRef} className="relative">
|
||||||
@@ -43,7 +46,7 @@ export function SettingsMenu() {
|
|||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="card absolute right-0 top-full z-20 mt-2 w-52 overflow-hidden p-1">
|
<div className="card absolute right-0 top-full z-20 mt-2 w-52 overflow-hidden p-1">
|
||||||
{items.map(({ href, label, icon: Icon }) => {
|
{visibleItems.map(({ href, label, icon: Icon }) => {
|
||||||
const active = pathname.startsWith(href);
|
const active = pathname.startsWith(href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Plus, Trash2, Download } from "lucide-react";
|
||||||
|
import type { UserAccountDTO } from "@/lib/auth/users";
|
||||||
|
|
||||||
|
interface LdapEntry {
|
||||||
|
dn: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<UserAccountDTO["role"], string> = { admin: "Администратор", agent: "Агент" };
|
||||||
|
|
||||||
|
export function AccountsManager({
|
||||||
|
initialUsers,
|
||||||
|
currentUserId,
|
||||||
|
ldapEnabled,
|
||||||
|
}: {
|
||||||
|
initialUsers: UserAccountDTO[];
|
||||||
|
currentUserId: string;
|
||||||
|
ldapEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [users, setUsers] = useState(initialUsers);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [role, setRole] = useState<UserAccountDTO["role"]>("agent");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [directory, setDirectory] = useState<LdapEntry[] | null>(null);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [directoryLoading, setDirectoryLoading] = useState(false);
|
||||||
|
const [directoryError, setDirectoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, password, role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось создать аккаунт");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await res.json();
|
||||||
|
setUsers((prev) => [...prev, user].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setRole("agent");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRoleChange(id: string, newRole: UserAccountDTO["role"]) {
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ role: newRole }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось изменить роль");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { user } = await res.json();
|
||||||
|
setUsers((prev) => prev.map((u) => (u.id === id ? user : u)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
setError(null);
|
||||||
|
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось удалить аккаунт");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDirectory() {
|
||||||
|
setDirectoryLoading(true);
|
||||||
|
setDirectoryError(null);
|
||||||
|
const res = await fetch("/api/ldap/directory");
|
||||||
|
setDirectoryLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setDirectoryError(data?.error ?? "Не удалось получить список из LDAP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setDirectory(data.entries);
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(email: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(email)) next.delete(email);
|
||||||
|
else next.add(email);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setDirectoryLoading(true);
|
||||||
|
setDirectoryError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/ldap/directory/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ emails: Array.from(selected) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
setDirectoryLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setDirectoryError(data?.error ?? "Не удалось импортировать");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usersRes = await fetch("/api/users");
|
||||||
|
if (usersRes.ok) {
|
||||||
|
const { users: fresh } = await usersRes.json();
|
||||||
|
setUsers(fresh);
|
||||||
|
}
|
||||||
|
setDirectory((prev) => (prev ? prev.filter((e) => !selected.has(e.email)) : prev));
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4">
|
||||||
|
<h2 className="text-sm font-semibold">Создать аккаунт</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Имя</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Роль</span>
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as UserAccountDTO["role"])}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="agent">Агент</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary self-start">
|
||||||
|
<Plus size={15} />
|
||||||
|
Создать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">Аккаунты ({users.length})</h2>
|
||||||
|
<div className="flex flex-col divide-y divide-border">
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="flex flex-wrap items-center gap-3 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{u.name}</p>
|
||||||
|
<p className="truncate text-xs text-text-muted">{u.email}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||||
|
u.authSource === "ldap" ? "bg-info-soft text-info-soft-text" : "bg-surface-hover text-text-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{u.authSource === "ldap" ? "LDAP" : "Локальный"}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={u.role}
|
||||||
|
onChange={(e) => handleRoleChange(u.id, e.target.value as UserAccountDTO["role"])}
|
||||||
|
className="rounded-md border border-border bg-surface px-2 py-1 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="agent">{ROLE_LABELS.agent}</option>
|
||||||
|
<option value="admin">{ROLE_LABELS.admin}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
disabled={u.id === currentUserId}
|
||||||
|
title={u.id === currentUserId ? "Нельзя удалить собственный аккаунт" : "Удалить"}
|
||||||
|
className="btn btn-ghost text-danger disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4">
|
||||||
|
<h2 className="mb-1 text-sm font-semibold">Импорт из LDAP</h2>
|
||||||
|
{!ldapEnabled ? (
|
||||||
|
<p className="text-sm text-text-muted">
|
||||||
|
LDAP не подключён — настройте его на странице «LDAP», чтобы импортировать аккаунты из каталога.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-sm text-text-muted">Найдите аккаунты из каталога, которых ещё нет в системе.</p>
|
||||||
|
<button onClick={loadDirectory} disabled={directoryLoading} className="btn btn-ghost mb-3">
|
||||||
|
<Download size={14} />
|
||||||
|
Проверить каталог
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{directoryError && (
|
||||||
|
<p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{directoryError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{directory && (
|
||||||
|
<>
|
||||||
|
{directory.length === 0 ? (
|
||||||
|
<p className="text-sm text-text-muted">Новых аккаунтов не найдено — всё уже импортировано.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-3 flex flex-col divide-y divide-border">
|
||||||
|
{directory.map((entry) => (
|
||||||
|
<label key={entry.email} className="flex items-center gap-2 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(entry.email)}
|
||||||
|
onChange={() => toggleSelected(entry.email)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{entry.name}</span>
|
||||||
|
<span className="text-text-muted">{entry.email}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{directory.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={directoryLoading || selected.size === 0}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Импортировать выбранные ({selected.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { listUsers } from "@/lib/auth/users";
|
||||||
|
import { getLdapStatus } from "@/lib/auth/ldap-config";
|
||||||
|
import { AccountsManager } from "./accounts-manager";
|
||||||
|
|
||||||
|
export default async function AccountsSettingsPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [users, ldapStatus] = await Promise.all([listUsers(), getLdapStatus()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="mb-1 text-xl font-bold tracking-tight">Аккаунты</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">
|
||||||
|
Управление аккаунтами агентов и администраторов — создание вручную и импорт из LDAP.
|
||||||
|
</p>
|
||||||
|
<AccountsManager
|
||||||
|
initialUsers={users}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
ldapEnabled={ldapStatus.enabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CheckCircle2, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
interface Status {
|
||||||
|
configured: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
host: string | null;
|
||||||
|
port: number | null;
|
||||||
|
useTls: boolean;
|
||||||
|
bindDn: string | null;
|
||||||
|
baseDn: string | null;
|
||||||
|
userFilter: string | null;
|
||||||
|
listFilter: string | null;
|
||||||
|
defaultRole: "admin" | "agent";
|
||||||
|
verifiedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LdapSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||||
|
const [status, setStatus] = useState(initialStatus);
|
||||||
|
const [host, setHost] = useState(initialStatus.host ?? "");
|
||||||
|
const [port, setPort] = useState(String(initialStatus.port ?? 389));
|
||||||
|
const [useTls, setUseTls] = useState(initialStatus.useTls);
|
||||||
|
const [bindDn, setBindDn] = useState(initialStatus.bindDn ?? "");
|
||||||
|
const [bindPassword, setBindPassword] = useState("");
|
||||||
|
const [baseDn, setBaseDn] = useState(initialStatus.baseDn ?? "");
|
||||||
|
const [userFilter, setUserFilter] = useState(initialStatus.userFilter ?? "(mail={{email}})");
|
||||||
|
const [listFilter, setListFilter] = useState(initialStatus.listFilter ?? "(objectClass=person)");
|
||||||
|
const [defaultRole, setDefaultRole] = useState<"admin" | "agent">(initialStatus.defaultRole);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleConnect(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch("/api/ldap/config", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
host,
|
||||||
|
port: Number(port),
|
||||||
|
useTls,
|
||||||
|
bindDn,
|
||||||
|
bindPassword,
|
||||||
|
baseDn,
|
||||||
|
userFilter,
|
||||||
|
listFilter,
|
||||||
|
defaultRole,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось подключить LDAP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({
|
||||||
|
configured: true,
|
||||||
|
enabled: true,
|
||||||
|
host,
|
||||||
|
port: Number(port),
|
||||||
|
useTls,
|
||||||
|
bindDn,
|
||||||
|
baseDn,
|
||||||
|
userFilter,
|
||||||
|
listFilter,
|
||||||
|
defaultRole,
|
||||||
|
verifiedAt: Date.now(),
|
||||||
|
});
|
||||||
|
setBindPassword("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch("/api/ldap/config", { method: "DELETE" });
|
||||||
|
setLoading(false);
|
||||||
|
setStatus((s) => ({ ...s, enabled: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||||
|
{status.enabled ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={16} className="text-success" />
|
||||||
|
<span>Подключено: {status.host}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle size={16} className="text-text-faint" />
|
||||||
|
<span className="text-text-muted">LDAP не подключён</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleConnect}>
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Хост</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={host}
|
||||||
|
onChange={(e) => setHost(e.target.value)}
|
||||||
|
placeholder="ldap.example.local"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Порт</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={port}
|
||||||
|
onChange={(e) => setPort(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-end gap-2 pb-2 text-sm text-text-muted">
|
||||||
|
<input type="checkbox" checked={useTls} onChange={(e) => setUseTls(e.target.checked)} />
|
||||||
|
Использовать TLS (ldaps://)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Bind DN (служебная учётка)</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={bindDn}
|
||||||
|
onChange={(e) => setBindDn(e.target.value)}
|
||||||
|
placeholder="cn=svc-helpdesk,dc=example,dc=local"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Bind пароль</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={bindPassword}
|
||||||
|
onChange={(e) => setBindPassword(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Base DN</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={baseDn}
|
||||||
|
onChange={(e) => setBaseDn(e.target.value)}
|
||||||
|
placeholder="dc=example,dc=local"
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Фильтр поиска пользователя при входе</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={userFilter}
|
||||||
|
onChange={(e) => setUserFilter(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block text-xs text-text-faint">{"{{email}}"} заменяется на введённый при входе email</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-3 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Фильтр для импорта каталога</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={listFilter}
|
||||||
|
onChange={(e) => setListFilter(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="mb-4 block text-sm">
|
||||||
|
<span className="mb-1 block font-medium text-text-muted">Роль по умолчанию для новых LDAP-аккаунтов</span>
|
||||||
|
<select
|
||||||
|
value={defaultRole}
|
||||||
|
onChange={(e) => setDefaultRole(e.target.value as "admin" | "agent")}
|
||||||
|
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="agent">Агент</option>
|
||||||
|
<option value="admin">Администратор</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{status.configured ? "Обновить" : "Подключить"}
|
||||||
|
</button>
|
||||||
|
{status.enabled && (
|
||||||
|
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
|
import { getLdapStatus } from "@/lib/auth/ldap-config";
|
||||||
|
import { LdapSettingsForm } from "./ldap-settings-form";
|
||||||
|
|
||||||
|
export default async function LdapSettingsPage() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await getLdapStatus();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<h1 className="mb-1 text-xl font-bold tracking-tight">LDAP</h1>
|
||||||
|
<p className="mb-6 text-sm text-text-muted">
|
||||||
|
Подключите каталог LDAP/AD — вход будет сначала пробовать LDAP, а при неудаче откатываться на локальный
|
||||||
|
пароль. Пока LDAP не подключён, локальный вход работает как обычно.
|
||||||
|
</p>
|
||||||
|
<LdapSettingsForm initialStatus={status} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Этот аккаунт входит через LDAP — локальный пароль не используется" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const ok = await verifyPassword(user.passwordHash, parsed.data.currentPassword);
|
const ok = await verifyPassword(user.passwordHash, parsed.data.currentPassword);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { z } from "zod";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { verifyPassword } from "@/lib/auth/password";
|
import { verifyPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
|
import { authenticateLdapUser } from "@/lib/ldap/client";
|
||||||
|
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||||
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
@@ -40,6 +43,25 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
|
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LDAP first — if configured and this login succeeds against the
|
||||||
|
// directory, JIT-provision/reuse the local account and skip local auth
|
||||||
|
// entirely. A directory outage or non-match returns null, never throws,
|
||||||
|
// so it always falls through cleanly to the local password check below.
|
||||||
|
const ldapUser = await authenticateLdapUser(email, parsed.data.password);
|
||||||
|
if (ldapUser) {
|
||||||
|
const ldapSettings = await getLdapSettings();
|
||||||
|
const user = await findOrCreateUserFromLdap({
|
||||||
|
email: ldapUser.email,
|
||||||
|
name: ldapUser.name,
|
||||||
|
defaultRole: ldapSettings?.defaultRole ?? "agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await createSession(user.id);
|
||||||
|
await setSessionCookie(token);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } });
|
||||||
|
}
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: (u, { eq }) => eq(u.email, email),
|
where: (u, { eq }) => eq(u.email, email),
|
||||||
});
|
});
|
||||||
@@ -52,7 +74,7 @@ export async function POST(request: Request) {
|
|||||||
parsed.data.password,
|
parsed.data.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user || !ok) {
|
if (!user || !user.passwordHash || !ok) {
|
||||||
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require";
|
||||||
|
import { getLdapStatus, saveLdapSettings, disableLdap } from "@/lib/auth/ldap-config";
|
||||||
|
import { testLdapBind } from "@/lib/ldap/client";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
return NextResponse.json(await getLdapStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
const configureSchema = z.object({
|
||||||
|
host: z.string().min(1),
|
||||||
|
port: z.coerce.number().int().positive(),
|
||||||
|
useTls: z.boolean(),
|
||||||
|
bindDn: z.string().min(1),
|
||||||
|
bindPassword: z.string().min(1),
|
||||||
|
baseDn: z.string().min(1),
|
||||||
|
userFilter: z.string().min(1),
|
||||||
|
listFilter: z.string().min(1),
|
||||||
|
defaultRole: z.enum(["admin", "agent"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = configureSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await testLdapBind(parsed.data);
|
||||||
|
await saveLdapSettings(parsed.data);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
await disableLdap();
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require";
|
||||||
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
|
import { searchLdapDirectory } from "@/lib/ldap/client";
|
||||||
|
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||||
|
|
||||||
|
const importSchema = z.object({ emails: z.array(z.string().email()).min(1) });
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const settings = await getLdapSettings();
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json({ error: "LDAP не настроен" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = importSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await searchLdapDirectory();
|
||||||
|
const wanted = new Set(parsed.data.emails.map((e) => e.toLowerCase()));
|
||||||
|
const toImport = entries.filter((e) => wanted.has(e.email.toLowerCase()));
|
||||||
|
|
||||||
|
const imported = [];
|
||||||
|
for (const entry of toImport) {
|
||||||
|
const user = await findOrCreateUserFromLdap({
|
||||||
|
email: entry.email,
|
||||||
|
name: entry.name,
|
||||||
|
defaultRole: settings.defaultRole,
|
||||||
|
});
|
||||||
|
imported.push(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, imported });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Не удалось импортировать: ${message}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require";
|
||||||
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
|
import { searchLdapDirectory } from "@/lib/ldap/client";
|
||||||
|
import { listUsers } from "@/lib/auth/users";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const settings = await getLdapSettings();
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json({ configured: false, entries: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [entries, existing] = await Promise.all([searchLdapDirectory(), listUsers()]);
|
||||||
|
const existingEmails = new Set(existing.map((u) => u.email.toLowerCase()));
|
||||||
|
const fresh = entries.filter((e) => !existingEmails.has(e.email.toLowerCase()));
|
||||||
|
return NextResponse.json({ configured: true, entries: fresh });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: `Не удалось получить список из LDAP: ${message}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require";
|
||||||
|
import { updateUserRole, deleteUser } from "@/lib/auth/users";
|
||||||
|
|
||||||
|
const patchSchema = z.object({ role: z.enum(["admin", "agent"]) });
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = patchSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await updateUserRole(id, parsed.data.role);
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Не удалось изменить роль";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (id === session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Нельзя удалить собственный аккаунт" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUser(id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Не удалось удалить аккаунт";
|
||||||
|
return NextResponse.json({ error: message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require";
|
||||||
|
import { listUsers, createLocalUser } from "@/lib/auth/users";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
return NextResponse.json({ users: await listUsers() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
|
||||||
|
role: z.enum(["admin", "agent"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireAdminSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const parsed = createSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await createLocalUser(parsed.data);
|
||||||
|
return NextResponse.json({ user }, { status: 201 });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { ldapConfig } from "@/lib/db/schema";
|
||||||
|
import { encryptCredential, decryptCredential } from "@/lib/crypto/credentials";
|
||||||
|
|
||||||
|
export interface LdapSettings {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
useTls: boolean;
|
||||||
|
bindDn: string;
|
||||||
|
bindPassword: string;
|
||||||
|
baseDn: string;
|
||||||
|
userFilter: string;
|
||||||
|
listFilter: string;
|
||||||
|
defaultRole: "admin" | "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings for actually connecting — null if never configured or disabled. */
|
||||||
|
export async function getLdapSettings(): Promise<LdapSettings | null> {
|
||||||
|
const config = await db.query.ldapConfig.findFirst();
|
||||||
|
if (!config || !config.enabled) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
useTls: config.useTls,
|
||||||
|
bindDn: config.bindDn,
|
||||||
|
bindPassword: decryptCredential(config.bindPasswordEnc),
|
||||||
|
baseDn: config.baseDn,
|
||||||
|
userFilter: config.userFilter,
|
||||||
|
listFilter: config.listFilter,
|
||||||
|
defaultRole: config.defaultRole,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLdapSettings(settings: LdapSettings): Promise<void> {
|
||||||
|
const row = {
|
||||||
|
host: settings.host,
|
||||||
|
port: settings.port,
|
||||||
|
useTls: settings.useTls,
|
||||||
|
bindDn: settings.bindDn,
|
||||||
|
bindPasswordEnc: encryptCredential(settings.bindPassword),
|
||||||
|
baseDn: settings.baseDn,
|
||||||
|
userFilter: settings.userFilter,
|
||||||
|
listFilter: settings.listFilter,
|
||||||
|
defaultRole: settings.defaultRole,
|
||||||
|
enabled: true,
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await db.query.ldapConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
await db.update(ldapConfig).set(row).where(eq(ldapConfig.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(ldapConfig).values(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableLdap(): Promise<void> {
|
||||||
|
const existing = await db.query.ldapConfig.findFirst();
|
||||||
|
if (existing) {
|
||||||
|
await db.update(ldapConfig).set({ enabled: false }).where(eq(ldapConfig.id, existing.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLdapStatus() {
|
||||||
|
const config = await db.query.ldapConfig.findFirst();
|
||||||
|
return {
|
||||||
|
configured: Boolean(config),
|
||||||
|
enabled: Boolean(config?.enabled),
|
||||||
|
host: config?.host ?? null,
|
||||||
|
port: config?.port ?? null,
|
||||||
|
useTls: config?.useTls ?? false,
|
||||||
|
bindDn: config?.bindDn ?? null,
|
||||||
|
baseDn: config?.baseDn ?? null,
|
||||||
|
userFilter: config?.userFilter ?? null,
|
||||||
|
listFilter: config?.listFilter ?? null,
|
||||||
|
defaultRole: config?.defaultRole ?? "agent",
|
||||||
|
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,3 +9,15 @@ export async function requireSession() {
|
|||||||
}
|
}
|
||||||
return { session, response: null };
|
return { session, response: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same as requireSession, but also requires role === "admin" — for account/LDAP management. */
|
||||||
|
export async function requireAdminSession() {
|
||||||
|
const session = await getCurrentSession();
|
||||||
|
if (!session) {
|
||||||
|
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||||
|
}
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { session, response: null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { eq, ne, and, count } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { users } from "@/lib/db/schema";
|
||||||
|
import { hashPassword } from "@/lib/auth/password";
|
||||||
|
|
||||||
|
export interface UserAccountDTO {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "admin" | "agent";
|
||||||
|
authSource: "local" | "ldap";
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toUserDTO(user: typeof users.$inferSelect): UserAccountDTO {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
authSource: user.authSource,
|
||||||
|
createdAt: user.createdAt.getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUsers(): Promise<UserAccountDTO[]> {
|
||||||
|
const rows = await db.query.users.findMany({ orderBy: users.name });
|
||||||
|
return rows.map(toUserDTO);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLocalUser(params: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: "admin" | "agent";
|
||||||
|
}): Promise<UserAccountDTO> {
|
||||||
|
const passwordHash = await hashPassword(params.password);
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
name: params.name,
|
||||||
|
email: params.email.toLowerCase().trim(),
|
||||||
|
passwordHash,
|
||||||
|
role: params.role,
|
||||||
|
authSource: "local",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return toUserDTO(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminCount(excludingUserId?: string): Promise<number> {
|
||||||
|
const where = excludingUserId
|
||||||
|
? and(eq(users.role, "admin"), ne(users.id, excludingUserId))
|
||||||
|
: eq(users.role, "admin");
|
||||||
|
const [row] = await db.select({ n: count() }).from(users).where(where);
|
||||||
|
return row?.n ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserRole(id: string, role: "admin" | "agent"): Promise<UserAccountDTO> {
|
||||||
|
if (role === "agent") {
|
||||||
|
// Demoting the last admin would lock everyone out of admin-only pages.
|
||||||
|
const remainingAdmins = await adminCount(id);
|
||||||
|
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
|
||||||
|
if (target?.role === "admin" && remainingAdmins === 0) {
|
||||||
|
throw new Error("Нельзя понизить последнего администратора");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db.update(users).set({ role }).where(eq(users.id, id)).returning();
|
||||||
|
return toUserDTO(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(id: string): Promise<void> {
|
||||||
|
const target = await db.query.users.findFirst({ where: eq(users.id, id) });
|
||||||
|
if (target?.role === "admin") {
|
||||||
|
const remainingAdmins = await adminCount(id);
|
||||||
|
if (remainingAdmins === 0) {
|
||||||
|
throw new Error("Нельзя удалить последнего администратора");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JIT-provisions a local account for a successful LDAP login — never touched again on subsequent logins. */
|
||||||
|
export async function findOrCreateUserFromLdap(params: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
defaultRole: "admin" | "agent";
|
||||||
|
}): Promise<typeof users.$inferSelect> {
|
||||||
|
const normalized = params.email.toLowerCase().trim();
|
||||||
|
const existing = await db.query.users.findFirst({ where: eq(users.email, normalized) });
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
name: params.name,
|
||||||
|
email: normalized,
|
||||||
|
passwordHash: null,
|
||||||
|
role: params.defaultRole,
|
||||||
|
authSource: "ldap",
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
CREATE TABLE `ldap_config` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`host` text NOT NULL,
|
||||||
|
`port` integer DEFAULT 389 NOT NULL,
|
||||||
|
`use_tls` integer DEFAULT false NOT NULL,
|
||||||
|
`bind_dn` text NOT NULL,
|
||||||
|
`bind_password_enc` text NOT NULL,
|
||||||
|
`base_dn` text NOT NULL,
|
||||||
|
`user_filter` text DEFAULT '(mail={{email}})' NOT NULL,
|
||||||
|
`list_filter` text DEFAULT '(objectClass=person)' NOT NULL,
|
||||||
|
`default_role` text DEFAULT 'agent' NOT NULL,
|
||||||
|
`enabled` integer DEFAULT false NOT NULL,
|
||||||
|
`verified_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||||
|
CREATE TABLE `__new_users` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`password_hash` text,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`role` text DEFAULT 'agent' NOT NULL,
|
||||||
|
`auth_source` text DEFAULT 'local' NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO `__new_users`("id", "email", "password_hash", "name", "role", "created_at") SELECT "id", "email", "password_hash", "name", "role", "created_at" FROM `users`;--> statement-breakpoint
|
||||||
|
DROP TABLE `users`;--> statement-breakpoint
|
||||||
|
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
|
||||||
|
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
|
||||||
@@ -0,0 +1,953 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "980ae196-7c18-498e-87e9-34feba13f92d",
|
||||||
|
"prevId": "c974991b-5c77-4525-a20c-91b3b31a7a70",
|
||||||
|
"tables": {
|
||||||
|
"attachments": {
|
||||||
|
"name": "attachments",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"message_id": {
|
||||||
|
"name": "message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"name": "filename",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"mime_type": {
|
||||||
|
"name": "mime_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"name": "size_bytes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"storage_key": {
|
||||||
|
"name": "storage_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"attachments_message_id_messages_id_fk": {
|
||||||
|
"name": "attachments_message_id_messages_id_fk",
|
||||||
|
"tableFrom": "attachments",
|
||||||
|
"tableTo": "messages",
|
||||||
|
"columnsFrom": [
|
||||||
|
"message_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"canned_responses": {
|
||||||
|
"name": "canned_responses",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"customers": {
|
||||||
|
"name": "customers",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"display_name": {
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"telegram_chat_id": {
|
||||||
|
"name": "telegram_chat_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"portal_token": {
|
||||||
|
"name": "portal_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"customers_telegram_chat_id_unique": {
|
||||||
|
"name": "customers_telegram_chat_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"telegram_chat_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"customers_portal_token_unique": {
|
||||||
|
"name": "customers_portal_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"portal_token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ldap_config": {
|
||||||
|
"name": "ldap_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"name": "host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"name": "port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 389
|
||||||
|
},
|
||||||
|
"use_tls": {
|
||||||
|
"name": "use_tls",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"bind_dn": {
|
||||||
|
"name": "bind_dn",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bind_password_enc": {
|
||||||
|
"name": "bind_password_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"base_dn": {
|
||||||
|
"name": "base_dn",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_filter": {
|
||||||
|
"name": "user_filter",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'(mail={{email}})'"
|
||||||
|
},
|
||||||
|
"list_filter": {
|
||||||
|
"name": "list_filter",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'(objectClass=person)'"
|
||||||
|
},
|
||||||
|
"default_role": {
|
||||||
|
"name": "default_role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'agent'"
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"mailbox_config": {
|
||||||
|
"name": "mailbox_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_host": {
|
||||||
|
"name": "imap_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"imap_port": {
|
||||||
|
"name": "imap_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 993
|
||||||
|
},
|
||||||
|
"smtp_host": {
|
||||||
|
"name": "smtp_host",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"smtp_port": {
|
||||||
|
"name": "smtp_port",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 587
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_enc": {
|
||||||
|
"name": "password_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allow_insecure_tls": {
|
||||||
|
"name": "allow_insecure_tls",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"name": "messages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ticket_id": {
|
||||||
|
"name": "ticket_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_type": {
|
||||||
|
"name": "author_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_id": {
|
||||||
|
"name": "author_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"author_name": {
|
||||||
|
"name": "author_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"name": "body",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"direction": {
|
||||||
|
"name": "direction",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"name": "visibility",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'public'"
|
||||||
|
},
|
||||||
|
"email_message_id": {
|
||||||
|
"name": "email_message_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"messages_ticket_id_tickets_id_fk": {
|
||||||
|
"name": "messages_ticket_id_tickets_id_fk",
|
||||||
|
"tableFrom": "messages",
|
||||||
|
"tableTo": "tickets",
|
||||||
|
"columnsFrom": [
|
||||||
|
"ticket_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'accent'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tags_name_unique": {
|
||||||
|
"name": "tags_name_unique",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"telegram_config": {
|
||||||
|
"name": "telegram_config",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_token_enc": {
|
||||||
|
"name": "bot_token_enc",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"bot_username": {
|
||||||
|
"name": "bot_username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"verified_at": {
|
||||||
|
"name": "verified_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"ticket_tags": {
|
||||||
|
"name": "ticket_tags",
|
||||||
|
"columns": {
|
||||||
|
"ticket_id": {
|
||||||
|
"name": "ticket_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"ticket_tags_ticket_id_tickets_id_fk": {
|
||||||
|
"name": "ticket_tags_ticket_id_tickets_id_fk",
|
||||||
|
"tableFrom": "ticket_tags",
|
||||||
|
"tableTo": "tickets",
|
||||||
|
"columnsFrom": [
|
||||||
|
"ticket_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"ticket_tags_tag_id_tags_id_fk": {
|
||||||
|
"name": "ticket_tags_tag_id_tags_id_fk",
|
||||||
|
"tableFrom": "ticket_tags",
|
||||||
|
"tableTo": "tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"ticket_tags_ticket_id_tag_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"ticket_id",
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"name": "ticket_tags_ticket_id_tag_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tickets": {
|
||||||
|
"name": "tickets",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"name": "subject",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'new'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'normal'"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"name": "channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"customer_id": {
|
||||||
|
"name": "customer_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"assignee_id": {
|
||||||
|
"name": "assignee_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_message_at": {
|
||||||
|
"name": "last_message_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tickets_customer_id_customers_id_fk": {
|
||||||
|
"name": "tickets_customer_id_customers_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "customers",
|
||||||
|
"columnsFrom": [
|
||||||
|
"customer_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"tickets_assignee_id_users_id_fk": {
|
||||||
|
"name": "tickets_assignee_id_users_id_fk",
|
||||||
|
"tableFrom": "tickets",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"assignee_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'agent'"
|
||||||
|
},
|
||||||
|
"auth_source": {
|
||||||
|
"name": "auth_source",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"widget_sites": {
|
||||||
|
"name": "widget_sites",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"site_key": {
|
||||||
|
"name": "site_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"allowed_origin": {
|
||||||
|
"name": "allowed_origin",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"enabled": {
|
||||||
|
"name": "enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch('subsec') * 1000)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"widget_sites_site_key_unique": {
|
||||||
|
"name": "widget_sites_site_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"site_key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1785085538509,
|
"when": 1785085538509,
|
||||||
"tag": "0004_familiar_kronos",
|
"tag": "0004_familiar_kronos",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785138258070,
|
||||||
|
"tag": "0005_colorful_gorilla_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+27
-1
@@ -20,11 +20,16 @@ const timestamps = {
|
|||||||
export const users = sqliteTable("users", {
|
export const users = sqliteTable("users", {
|
||||||
id: id(),
|
id: id(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
// Null for LDAP-provisioned accounts — they have no local password, and
|
||||||
|
// the login route must never pass null into argon2's verify.
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
role: text("role", { enum: ["admin", "agent"] })
|
role: text("role", { enum: ["admin", "agent"] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("agent"),
|
.default("agent"),
|
||||||
|
authSource: text("auth_source", { enum: ["local", "ldap"] })
|
||||||
|
.notNull()
|
||||||
|
.default("local"),
|
||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,6 +132,27 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
|
|||||||
createdAt: timestamps.createdAt,
|
createdAt: timestamps.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Single-row-ish config for LDAP login (one directory per deployment). */
|
||||||
|
export const ldapConfig = sqliteTable("ldap_config", {
|
||||||
|
id: id(),
|
||||||
|
host: text("host").notNull(),
|
||||||
|
port: integer("port").notNull().default(389),
|
||||||
|
useTls: integer("use_tls", { mode: "boolean" }).notNull().default(false),
|
||||||
|
bindDn: text("bind_dn").notNull(),
|
||||||
|
bindPasswordEnc: text("bind_password_enc").notNull(),
|
||||||
|
baseDn: text("base_dn").notNull(),
|
||||||
|
// "{{email}}" is substituted with the login email at auth time.
|
||||||
|
userFilter: text("user_filter").notNull().default("(mail={{email}})"),
|
||||||
|
// Broader filter used only by the "browse directory to import" admin UI.
|
||||||
|
listFilter: text("list_filter").notNull().default("(objectClass=person)"),
|
||||||
|
defaultRole: text("default_role", { enum: ["admin", "agent"] })
|
||||||
|
.notNull()
|
||||||
|
.default("agent"),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
|
||||||
|
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||||
|
createdAt: timestamps.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
/** A site the chat widget is embedded on. Public, non-secret identifier — labels ticket origin, not a trust boundary. */
|
/** A site the chat widget is embedded on. Public, non-secret identifier — labels ticket origin, not a trust boundary. */
|
||||||
export const widgetSites = sqliteTable("widget_sites", {
|
export const widgetSites = sqliteTable("widget_sites", {
|
||||||
id: id(),
|
id: id(),
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import ldap from "ldapjs";
|
||||||
|
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||||
|
|
||||||
|
export interface LdapUserInfo {
|
||||||
|
dn: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionSettings {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
useTls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLdapClient(settings: ConnectionSettings): ldap.Client {
|
||||||
|
const protocol = settings.useTls ? "ldaps" : "ldap";
|
||||||
|
const client = ldap.createClient({
|
||||||
|
url: `${protocol}://${settings.host}:${settings.port}`,
|
||||||
|
timeout: 5_000,
|
||||||
|
connectTimeout: 5_000,
|
||||||
|
});
|
||||||
|
// A socket-level error (e.g. host unreachable) with no listener would
|
||||||
|
// throw and crash the process — swallow it here, every call site already
|
||||||
|
// handles failure via the bind/search callback's error argument.
|
||||||
|
client.on("error", () => {});
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind(client: ldap.Client, dn: string, password: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(
|
||||||
|
client: ldap.Client,
|
||||||
|
baseDn: string,
|
||||||
|
filter: string,
|
||||||
|
): Promise<{ dn: string; attributes: Record<string, string> }[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const results: { dn: string; attributes: Record<string, string> }[] = [];
|
||||||
|
client.search(baseDn, { filter, scope: "sub" }, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.on("searchEntry", (entry) => {
|
||||||
|
const attributes: Record<string, string> = {};
|
||||||
|
for (const attr of entry.pojo.attributes) {
|
||||||
|
attributes[attr.type] = attr.values[0] ?? "";
|
||||||
|
}
|
||||||
|
results.push({ dn: entry.objectName ?? entry.pojo.objectName, attributes });
|
||||||
|
});
|
||||||
|
res.on("error", (searchErr) => reject(searchErr));
|
||||||
|
res.on("end", () => resolve(results));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindQuietly(client: ldap.Client): void {
|
||||||
|
client.unbind(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escapes an untrusted value for safe interpolation into an LDAP search filter (RFC 4515). */
|
||||||
|
function escapeFilterValue(value: string): string {
|
||||||
|
return value.replace(/[\\*()\0]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binds with the service account only — used to validate settings before saving. Throws on failure. */
|
||||||
|
export async function testLdapBind(settings: ConnectionSettings & { bindDn: string; bindPassword: string }): Promise<void> {
|
||||||
|
const client = createLdapClient(settings);
|
||||||
|
try {
|
||||||
|
await bind(client, settings.bindDn, settings.bindPassword);
|
||||||
|
} finally {
|
||||||
|
unbindQuietly(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search-then-bind: finds the user by userFilter, then re-binds as their
|
||||||
|
* own DN with the supplied password to actually verify it. Every failure
|
||||||
|
* mode (not configured, disabled, unreachable, no match, wrong password)
|
||||||
|
* normalizes to null — a directory outage must never break local login.
|
||||||
|
*/
|
||||||
|
export async function authenticateLdapUser(email: string, password: string): Promise<LdapUserInfo | null> {
|
||||||
|
const settings = await getLdapSettings();
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
const serviceClient = createLdapClient(settings);
|
||||||
|
try {
|
||||||
|
await bind(serviceClient, settings.bindDn, settings.bindPassword);
|
||||||
|
const filter = settings.userFilter.replace("{{email}}", escapeFilterValue(email));
|
||||||
|
const entries = await search(serviceClient, settings.baseDn, filter);
|
||||||
|
|
||||||
|
const match = entries[0];
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const userClient = createLdapClient(settings);
|
||||||
|
try {
|
||||||
|
await bind(userClient, match.dn, password);
|
||||||
|
} finally {
|
||||||
|
unbindQuietly(userClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dn: match.dn,
|
||||||
|
email: match.attributes.mail || email,
|
||||||
|
name: match.attributes.cn || match.attributes.displayName || email,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
unbindQuietly(serviceClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broad directory browse (listFilter) for the "import accounts from LDAP" admin UI. */
|
||||||
|
export async function searchLdapDirectory(): Promise<LdapUserInfo[]> {
|
||||||
|
const settings = await getLdapSettings();
|
||||||
|
if (!settings) return [];
|
||||||
|
|
||||||
|
const client = createLdapClient(settings);
|
||||||
|
try {
|
||||||
|
await bind(client, settings.bindDn, settings.bindPassword);
|
||||||
|
const entries = await search(client, settings.baseDn, settings.listFilter);
|
||||||
|
return entries
|
||||||
|
.filter((e) => e.attributes.mail)
|
||||||
|
.map((e) => ({
|
||||||
|
dn: e.dn,
|
||||||
|
email: e.attributes.mail,
|
||||||
|
name: e.attributes.cn || e.attributes.displayName || e.attributes.mail,
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
unbindQuietly(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user