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:
ogrechkoandClaude Sonnet 5 committed 2026-07-27 08:56:08 +00:00
1 parent 8121d8aa51
commit cb12711e91
23 files changed
+2379 -13

No files matched your search

+221
View File
@@ -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",
+2
View File
@@ -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",
+1 -1
View File
@@ -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>
+13 -10
View File
@@ -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>
);
}
+24
View File
@@ -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) {
+23 -1
View File
@@ -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 });
} }
+54
View File
@@ -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 });
}
}
+27
View File
@@ -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 });
}
}
+46
View File
@@ -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 });
}
}
+38
View File
@@ -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 });
}
}
+81
View File
@@ -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,
};
}
+12
View File
@@ -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 };
}
+105
View File
@@ -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": {}
}
}
+7
View File
@@ -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
View File
@@ -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(),
+137
View File
@@ -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);
}
}