diff --git a/package-lock.json b/package-lock.json
index 4efdc35..c4afe49 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"imapflow": "^1.5.0",
+ "ldapjs": "^3.0.7",
"lucide-react": "^0.545.0",
"mailparser": "^3.9.14",
"next": "16.2.12",
@@ -25,6 +26,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@types/better-sqlite3": "^7.6.13",
+ "@types/ldapjs": "^3.0.6",
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
@@ -1824,6 +1826,112 @@
"@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": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -2701,6 +2809,15 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"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": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
@@ -3468,6 +3585,11 @@
"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": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -3702,6 +3824,14 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -3767,6 +3897,17 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4102,6 +4243,11 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"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": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -5243,6 +5389,14 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6435,6 +6589,28 @@
"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": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
@@ -7523,6 +7699,14 @@
"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": {
"version": "1.2.1",
"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",
"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": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
diff --git a/package.json b/package.json
index 352ccfc..cdb9e36 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"drizzle-orm": "^0.45.2",
"framer-motion": "^12.4.7",
"imapflow": "^1.5.0",
+ "ldapjs": "^3.0.7",
"lucide-react": "^0.545.0",
"mailparser": "^3.9.14",
"next": "16.2.12",
@@ -31,6 +32,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.16",
"@types/better-sqlite3": "^7.6.13",
+ "@types/ldapjs": "^3.0.6",
"@types/mailparser": "^3.4.6",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx
index 5e47295..fd73e13 100644
--- a/src/app/(admin)/layout.tsx
+++ b/src/app/(admin)/layout.tsx
@@ -27,7 +27,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
{session.user.name}
-
+
diff --git a/src/app/(admin)/settings-menu.tsx b/src/app/(admin)/settings-menu.tsx
index 566a5af..10ef1f6 100644
--- a/src/app/(admin)/settings-menu.tsx
+++ b/src/app/(admin)/settings-menu.tsx
@@ -3,21 +3,24 @@
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
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 = [
- { href: "/settings/telegram", label: "Telegram", icon: Send },
- { href: "/settings/email", label: "Почта", icon: Mail },
- { href: "/settings/widget", label: "Виджет", icon: Code2 },
- { href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText },
- { href: "/settings/tags", label: "Теги", icon: Tags },
- { href: "/settings/account", label: "Аккаунт", icon: UserCog },
+ { href: "/settings/telegram", label: "Telegram", icon: Send, adminOnly: false },
+ { href: "/settings/email", label: "Почта", icon: Mail, adminOnly: false },
+ { href: "/settings/widget", label: "Виджет", icon: Code2, adminOnly: false },
+ { href: "/settings/canned", label: "Шаблоны ответов", icon: MessageSquareText, adminOnly: false },
+ { href: "/settings/tags", label: "Теги", icon: Tags, adminOnly: false },
+ { 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 pathname = usePathname();
const rootRef = useRef(null);
+ const visibleItems = items.filter((item) => !item.adminOnly || isAdmin);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
@@ -29,7 +32,7 @@ export function SettingsMenu() {
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
- const isActive = items.some((item) => pathname.startsWith(item.href));
+ const isActive = visibleItems.some((item) => pathname.startsWith(item.href));
return (
@@ -43,7 +46,7 @@ export function SettingsMenu() {
{open && (
- {items.map(({ href, label, icon: Icon }) => {
+ {visibleItems.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href);
return (
= { 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
("agent");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [directory, setDirectory] = useState(null);
+ const [selected, setSelected] = useState>(new Set());
+ const [directoryLoading, setDirectoryLoading] = useState(false);
+ const [directoryError, setDirectoryError] = useState(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 (
+
+
+
+
+
Аккаунты ({users.length})
+
+ {users.map((u) => (
+
+
+
+ {u.authSource === "ldap" ? "LDAP" : "Локальный"}
+
+
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"
+ >
+ {ROLE_LABELS.agent}
+ {ROLE_LABELS.admin}
+
+
handleDelete(u.id)}
+ disabled={u.id === currentUserId}
+ title={u.id === currentUserId ? "Нельзя удалить собственный аккаунт" : "Удалить"}
+ className="btn btn-ghost text-danger disabled:opacity-30"
+ >
+
+
+
+ ))}
+
+
+
+
+
Импорт из LDAP
+ {!ldapEnabled ? (
+
+ LDAP не подключён — настройте его на странице «LDAP», чтобы импортировать аккаунты из каталога.
+
+ ) : (
+ <>
+
Найдите аккаунты из каталога, которых ещё нет в системе.
+
+
+ Проверить каталог
+
+
+ {directoryError && (
+
{directoryError}
+ )}
+
+ {directory && (
+ <>
+ {directory.length === 0 ? (
+
Новых аккаунтов не найдено — всё уже импортировано.
+ ) : (
+
+ {directory.map((entry) => (
+
+ toggleSelected(entry.email)}
+ />
+ {entry.name}
+ {entry.email}
+
+ ))}
+
+ )}
+ {directory.length > 0 && (
+
+ Импортировать выбранные ({selected.size})
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/(admin)/settings/accounts/page.tsx b/src/app/(admin)/settings/accounts/page.tsx
new file mode 100644
index 0000000..83e1d88
--- /dev/null
+++ b/src/app/(admin)/settings/accounts/page.tsx
@@ -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 (
+
+
Аккаунты
+
+ Управление аккаунтами агентов и администраторов — создание вручную и импорт из LDAP.
+
+
+
+ );
+}
diff --git a/src/app/(admin)/settings/ldap/ldap-settings-form.tsx b/src/app/(admin)/settings/ldap/ldap-settings-form.tsx
new file mode 100644
index 0000000..3732c1c
--- /dev/null
+++ b/src/app/(admin)/settings/ldap/ldap-settings-form.tsx
@@ -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(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 (
+
+
+ {status.enabled ? (
+ <>
+
+ Подключено: {status.host}
+ >
+ ) : (
+ <>
+
+ LDAP не подключён
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/(admin)/settings/ldap/page.tsx b/src/app/(admin)/settings/ldap/page.tsx
new file mode 100644
index 0000000..67966d9
--- /dev/null
+++ b/src/app/(admin)/settings/ldap/page.tsx
@@ -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 (
+
+
LDAP
+
+ Подключите каталог LDAP/AD — вход будет сначала пробовать LDAP, а при неудаче откатываться на локальный
+ пароль. Пока LDAP не подключён, локальный вход работает как обычно.
+
+
+
+ );
+}
diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts
index afacf22..3e97e98 100644
--- a/src/app/api/auth/change-password/route.ts
+++ b/src/app/api/auth/change-password/route.ts
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
if (!user) {
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);
if (!ok) {
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 8c7ae3b..64ab77e 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -5,6 +5,9 @@ import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
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({
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 });
}
+ // 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({
where: (u, { eq }) => eq(u.email, email),
});
@@ -52,7 +74,7 @@ export async function POST(request: Request) {
parsed.data.password,
);
- if (!user || !ok) {
+ if (!user || !user.passwordHash || !ok) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
diff --git a/src/app/api/ldap/config/route.ts b/src/app/api/ldap/config/route.ts
new file mode 100644
index 0000000..e549fa1
--- /dev/null
+++ b/src/app/api/ldap/config/route.ts
@@ -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 });
+}
diff --git a/src/app/api/ldap/directory/import/route.ts b/src/app/api/ldap/directory/import/route.ts
new file mode 100644
index 0000000..484edcb
--- /dev/null
+++ b/src/app/api/ldap/directory/import/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/ldap/directory/route.ts b/src/app/api/ldap/directory/route.ts
new file mode 100644
index 0000000..f41024b
--- /dev/null
+++ b/src/app/api/ldap/directory/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/users/[id]/route.ts b/src/app/api/users/[id]/route.ts
new file mode 100644
index 0000000..3a8ca97
--- /dev/null
+++ b/src/app/api/users/[id]/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts
new file mode 100644
index 0000000..2b95ba4
--- /dev/null
+++ b/src/app/api/users/route.ts
@@ -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 });
+ }
+}
diff --git a/src/lib/auth/ldap-config.ts b/src/lib/auth/ldap-config.ts
new file mode 100644
index 0000000..931b16b
--- /dev/null
+++ b/src/lib/auth/ldap-config.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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,
+ };
+}
diff --git a/src/lib/auth/require.ts b/src/lib/auth/require.ts
index dec6b2e..a1048aa 100644
--- a/src/lib/auth/require.ts
+++ b/src/lib/auth/require.ts
@@ -9,3 +9,15 @@ export async function requireSession() {
}
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 };
+}
diff --git a/src/lib/auth/users.ts b/src/lib/auth/users.ts
new file mode 100644
index 0000000..f861f2f
--- /dev/null
+++ b/src/lib/auth/users.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/src/lib/db/migrations/0005_colorful_gorilla_man.sql b/src/lib/db/migrations/0005_colorful_gorilla_man.sql
new file mode 100644
index 0000000..b5248be
--- /dev/null
+++ b/src/lib/db/migrations/0005_colorful_gorilla_man.sql
@@ -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`);
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/0005_snapshot.json b/src/lib/db/migrations/meta/0005_snapshot.json
new file mode 100644
index 0000000..eea0610
--- /dev/null
+++ b/src/lib/db/migrations/meta/0005_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/src/lib/db/migrations/meta/_journal.json b/src/lib/db/migrations/meta/_journal.json
index ac7e9ca..4355c22 100644
--- a/src/lib/db/migrations/meta/_journal.json
+++ b/src/lib/db/migrations/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1785085538509,
"tag": "0004_familiar_kronos",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "6",
+ "when": 1785138258070,
+ "tag": "0005_colorful_gorilla_man",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 2a2d377..20e205d 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -20,11 +20,16 @@ const timestamps = {
export const users = sqliteTable("users", {
id: id(),
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(),
role: text("role", { enum: ["admin", "agent"] })
.notNull()
.default("agent"),
+ authSource: text("auth_source", { enum: ["local", "ldap"] })
+ .notNull()
+ .default("local"),
createdAt: timestamps.createdAt,
});
@@ -127,6 +132,27 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
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. */
export const widgetSites = sqliteTable("widget_sites", {
id: id(),
diff --git a/src/lib/ldap/client.ts b/src/lib/ldap/client.ts
new file mode 100644
index 0000000..2f30512
--- /dev/null
+++ b/src/lib/ldap/client.ts
@@ -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 {
+ 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 }[]> {
+ return new Promise((resolve, reject) => {
+ const results: { dn: string; attributes: Record }[] = [];
+ client.search(baseDn, { filter, scope: "sub" }, (err, res) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ res.on("searchEntry", (entry) => {
+ const attributes: Record = {};
+ 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+}