From cb12711e91d98ac9023f57dbea34a7ab032e0608 Mon Sep 17 00:00:00 2001 From: Oleg Date: Mon, 27 Jul 2026 08:56:08 +0000 Subject: [PATCH] Add account management and LDAP login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- package-lock.json | 221 ++++ package.json | 2 + src/app/(admin)/layout.tsx | 2 +- src/app/(admin)/settings-menu.tsx | 23 +- .../settings/accounts/accounts-manager.tsx | 285 ++++++ src/app/(admin)/settings/accounts/page.tsx | 28 + .../settings/ldap/ldap-settings-form.tsx | 210 ++++ src/app/(admin)/settings/ldap/page.tsx | 24 + src/app/api/auth/change-password/route.ts | 6 + src/app/api/auth/login/route.ts | 24 +- src/app/api/ldap/config/route.ts | 54 + src/app/api/ldap/directory/import/route.ts | 47 + src/app/api/ldap/directory/route.ts | 27 + src/app/api/users/[id]/route.ts | 46 + src/app/api/users/route.ts | 38 + src/lib/auth/ldap-config.ts | 81 ++ src/lib/auth/require.ts | 12 + src/lib/auth/users.ts | 105 ++ .../migrations/0005_colorful_gorilla_man.sql | 32 + src/lib/db/migrations/meta/0005_snapshot.json | 953 ++++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 28 +- src/lib/ldap/client.ts | 137 +++ 23 files changed, 2379 insertions(+), 13 deletions(-) create mode 100644 src/app/(admin)/settings/accounts/accounts-manager.tsx create mode 100644 src/app/(admin)/settings/accounts/page.tsx create mode 100644 src/app/(admin)/settings/ldap/ldap-settings-form.tsx create mode 100644 src/app/(admin)/settings/ldap/page.tsx create mode 100644 src/app/api/ldap/config/route.ts create mode 100644 src/app/api/ldap/directory/import/route.ts create mode 100644 src/app/api/ldap/directory/route.ts create mode 100644 src/app/api/users/[id]/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/lib/auth/ldap-config.ts create mode 100644 src/lib/auth/users.ts create mode 100644 src/lib/db/migrations/0005_colorful_gorilla_man.sql create mode 100644 src/lib/db/migrations/meta/0005_snapshot.json create mode 100644 src/lib/ldap/client.ts 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 ( +
+
+

Создать аккаунт

+
+ + + + +
+ {error &&

{error}

} + +
+ +
+

Аккаунты ({users.length})

+
+ {users.map((u) => ( +
+
+

{u.name}

+

{u.email}

+
+ + {u.authSource === "ldap" ? "LDAP" : "Локальный"} + + + +
+ ))} +
+
+ +
+

Импорт из LDAP

+ {!ldapEnabled ? ( +

+ LDAP не подключён — настройте его на странице «LDAP», чтобы импортировать аккаунты из каталога. +

+ ) : ( + <> +

Найдите аккаунты из каталога, которых ещё нет в системе.

+ + + {directoryError && ( +

{directoryError}

+ )} + + {directory && ( + <> + {directory.length === 0 ? ( +

Новых аккаунтов не найдено — всё уже импортировано.

+ ) : ( +
+ {directory.map((entry) => ( + + ))} +
+ )} + {directory.length > 0 && ( + + )} + + )} + + )} +
+
+ ); +} 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 не подключён + + )} +
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + {error &&

{error}

} + +
+ + {status.enabled && ( + + )} +
+
+
+ ); +} 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); + } +}