Add account management and LDAP login
Admins can now create local agent/admin accounts and configure LDAP directly from the UI (Настройки → Аккаунты / LDAP), with login trying LDAP first and falling back to the local password. This is the first place users.role is actually enforced (requireAdminSession). Fixed a bug in the generated 0005 migration: the INSERT into __new_users selected auth_source from the old users table, which doesn't have that column yet — caused drizzle-kit migrate to fail silently. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
1 parent
8121d8aa51
commit
cb12711e91
23 files changed
+2379
-13
No files matched your search
@@ -27,7 +27,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
||||
<span className="hidden text-sm text-text-muted sm:inline">{session.user.name}</span>
|
||||
<NotificationToggle />
|
||||
<SettingsMenu />
|
||||
<SettingsMenu isAdmin={session.user.role === "admin"} />
|
||||
<ThemeToggle />
|
||||
<LogoutButton />
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div ref={rootRef} className="relative">
|
||||
@@ -43,7 +46,7 @@ export function SettingsMenu() {
|
||||
</button>
|
||||
{open && (
|
||||
<div className="card absolute right-0 top-full z-20 mt-2 w-52 overflow-hidden p-1">
|
||||
{items.map(({ href, label, icon: Icon }) => {
|
||||
{visibleItems.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href);
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Trash2, Download } from "lucide-react";
|
||||
import type { UserAccountDTO } from "@/lib/auth/users";
|
||||
|
||||
interface LdapEntry {
|
||||
dn: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<UserAccountDTO["role"], string> = { admin: "Администратор", agent: "Агент" };
|
||||
|
||||
export function AccountsManager({
|
||||
initialUsers,
|
||||
currentUserId,
|
||||
ldapEnabled,
|
||||
}: {
|
||||
initialUsers: UserAccountDTO[];
|
||||
currentUserId: string;
|
||||
ldapEnabled: boolean;
|
||||
}) {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<UserAccountDTO["role"]>("agent");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [directory, setDirectory] = useState<LdapEntry[] | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [directoryLoading, setDirectoryLoading] = useState(false);
|
||||
const [directoryError, setDirectoryError] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password, role }),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось создать аккаунт");
|
||||
return;
|
||||
}
|
||||
|
||||
const { user } = await res.json();
|
||||
setUsers((prev) => [...prev, user].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setName("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
setRole("agent");
|
||||
}
|
||||
|
||||
async function handleRoleChange(id: string, newRole: UserAccountDTO["role"]) {
|
||||
setError(null);
|
||||
const res = await fetch(`/api/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось изменить роль");
|
||||
return;
|
||||
}
|
||||
const { user } = await res.json();
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? user : u)));
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setError(null);
|
||||
const res = await fetch(`/api/users/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось удалить аккаунт");
|
||||
return;
|
||||
}
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
}
|
||||
|
||||
async function loadDirectory() {
|
||||
setDirectoryLoading(true);
|
||||
setDirectoryError(null);
|
||||
const res = await fetch("/api/ldap/directory");
|
||||
setDirectoryLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setDirectoryError(data?.error ?? "Не удалось получить список из LDAP");
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
setDirectory(data.entries);
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
function toggleSelected(email: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(email)) next.delete(email);
|
||||
else next.add(email);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (selected.size === 0) return;
|
||||
setDirectoryLoading(true);
|
||||
setDirectoryError(null);
|
||||
|
||||
const res = await fetch("/api/ldap/directory/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emails: Array.from(selected) }),
|
||||
});
|
||||
|
||||
setDirectoryLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setDirectoryError(data?.error ?? "Не удалось импортировать");
|
||||
return;
|
||||
}
|
||||
|
||||
const usersRes = await fetch("/api/users");
|
||||
if (usersRes.ok) {
|
||||
const { users: fresh } = await usersRes.json();
|
||||
setUsers(fresh);
|
||||
}
|
||||
setDirectory((prev) => (prev ? prev.filter((e) => !selected.has(e.email)) : prev));
|
||||
setSelected(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<form onSubmit={handleCreate} className="card flex flex-col gap-3 p-4">
|
||||
<h2 className="text-sm font-semibold">Создать аккаунт</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Имя</span>
|
||||
<input
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Email</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Роль</span>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as UserAccountDTO["role"])}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="agent">Агент</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
<button type="submit" disabled={loading} className="btn btn-primary self-start">
|
||||
<Plus size={15} />
|
||||
Создать
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold">Аккаунты ({users.length})</h2>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex flex-wrap items-center gap-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{u.name}</p>
|
||||
<p className="truncate text-xs text-text-muted">{u.email}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
u.authSource === "ldap" ? "bg-info-soft text-info-soft-text" : "bg-surface-hover text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{u.authSource === "ldap" ? "LDAP" : "Локальный"}
|
||||
</span>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) => handleRoleChange(u.id, e.target.value as UserAccountDTO["role"])}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="agent">{ROLE_LABELS.agent}</option>
|
||||
<option value="admin">{ROLE_LABELS.admin}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id)}
|
||||
disabled={u.id === currentUserId}
|
||||
title={u.id === currentUserId ? "Нельзя удалить собственный аккаунт" : "Удалить"}
|
||||
className="btn btn-ghost text-danger disabled:opacity-30"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="mb-1 text-sm font-semibold">Импорт из LDAP</h2>
|
||||
{!ldapEnabled ? (
|
||||
<p className="text-sm text-text-muted">
|
||||
LDAP не подключён — настройте его на странице «LDAP», чтобы импортировать аккаунты из каталога.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-sm text-text-muted">Найдите аккаунты из каталога, которых ещё нет в системе.</p>
|
||||
<button onClick={loadDirectory} disabled={directoryLoading} className="btn btn-ghost mb-3">
|
||||
<Download size={14} />
|
||||
Проверить каталог
|
||||
</button>
|
||||
|
||||
{directoryError && (
|
||||
<p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{directoryError}</p>
|
||||
)}
|
||||
|
||||
{directory && (
|
||||
<>
|
||||
{directory.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">Новых аккаунтов не найдено — всё уже импортировано.</p>
|
||||
) : (
|
||||
<div className="mb-3 flex flex-col divide-y divide-border">
|
||||
{directory.map((entry) => (
|
||||
<label key={entry.email} className="flex items-center gap-2 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(entry.email)}
|
||||
onChange={() => toggleSelected(entry.email)}
|
||||
/>
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
<span className="text-text-muted">{entry.email}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{directory.length > 0 && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={directoryLoading || selected.size === 0}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Импортировать выбранные ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { listUsers } from "@/lib/auth/users";
|
||||
import { getLdapStatus } from "@/lib/auth/ldap-config";
|
||||
import { AccountsManager } from "./accounts-manager";
|
||||
|
||||
export default async function AccountsSettingsPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session || session.user.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const [users, ldapStatus] = await Promise.all([listUsers(), getLdapStatus()]);
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-tight">Аккаунты</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">
|
||||
Управление аккаунтами агентов и администраторов — создание вручную и импорт из LDAP.
|
||||
</p>
|
||||
<AccountsManager
|
||||
initialUsers={users}
|
||||
currentUserId={session.user.id}
|
||||
ldapEnabled={ldapStatus.enabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
interface Status {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
useTls: boolean;
|
||||
bindDn: string | null;
|
||||
baseDn: string | null;
|
||||
userFilter: string | null;
|
||||
listFilter: string | null;
|
||||
defaultRole: "admin" | "agent";
|
||||
verifiedAt: number | null;
|
||||
}
|
||||
|
||||
export function LdapSettingsForm({ initialStatus }: { initialStatus: Status }) {
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
const [host, setHost] = useState(initialStatus.host ?? "");
|
||||
const [port, setPort] = useState(String(initialStatus.port ?? 389));
|
||||
const [useTls, setUseTls] = useState(initialStatus.useTls);
|
||||
const [bindDn, setBindDn] = useState(initialStatus.bindDn ?? "");
|
||||
const [bindPassword, setBindPassword] = useState("");
|
||||
const [baseDn, setBaseDn] = useState(initialStatus.baseDn ?? "");
|
||||
const [userFilter, setUserFilter] = useState(initialStatus.userFilter ?? "(mail={{email}})");
|
||||
const [listFilter, setListFilter] = useState(initialStatus.listFilter ?? "(objectClass=person)");
|
||||
const [defaultRole, setDefaultRole] = useState<"admin" | "agent">(initialStatus.defaultRole);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleConnect(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch("/api/ldap/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
port: Number(port),
|
||||
useTls,
|
||||
bindDn,
|
||||
bindPassword,
|
||||
baseDn,
|
||||
userFilter,
|
||||
listFilter,
|
||||
defaultRole,
|
||||
}),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось подключить LDAP");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
host,
|
||||
port: Number(port),
|
||||
useTls,
|
||||
bindDn,
|
||||
baseDn,
|
||||
userFilter,
|
||||
listFilter,
|
||||
defaultRole,
|
||||
verifiedAt: Date.now(),
|
||||
});
|
||||
setBindPassword("");
|
||||
}
|
||||
|
||||
async function handleDisable() {
|
||||
setLoading(true);
|
||||
await fetch("/api/ldap/config", { method: "DELETE" });
|
||||
setLoading(false);
|
||||
setStatus((s) => ({ ...s, enabled: false }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm">
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-success" />
|
||||
<span>Подключено: {status.host}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle size={16} className="text-text-faint" />
|
||||
<span className="text-text-muted">LDAP не подключён</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleConnect}>
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Хост</span>
|
||||
<input
|
||||
required
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="ldap.example.local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Порт</span>
|
||||
<input
|
||||
required
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm text-text-muted">
|
||||
<input type="checkbox" checked={useTls} onChange={(e) => setUseTls(e.target.checked)} />
|
||||
Использовать TLS (ldaps://)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Bind DN (служебная учётка)</span>
|
||||
<input
|
||||
required
|
||||
value={bindDn}
|
||||
onChange={(e) => setBindDn(e.target.value)}
|
||||
placeholder="cn=svc-helpdesk,dc=example,dc=local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Bind пароль</span>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={bindPassword}
|
||||
onChange={(e) => setBindPassword(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Base DN</span>
|
||||
<input
|
||||
required
|
||||
value={baseDn}
|
||||
onChange={(e) => setBaseDn(e.target.value)}
|
||||
placeholder="dc=example,dc=local"
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Фильтр поиска пользователя при входе</span>
|
||||
<input
|
||||
required
|
||||
value={userFilter}
|
||||
onChange={(e) => setUserFilter(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<span className="mt-1 block text-xs text-text-faint">{"{{email}}"} заменяется на введённый при входе email</span>
|
||||
</label>
|
||||
|
||||
<label className="mb-3 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Фильтр для импорта каталога</span>
|
||||
<input
|
||||
required
|
||||
value={listFilter}
|
||||
onChange={(e) => setListFilter(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mb-4 block text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Роль по умолчанию для новых LDAP-аккаунтов</span>
|
||||
<select
|
||||
value={defaultRole}
|
||||
onChange={(e) => setDefaultRole(e.target.value as "admin" | "agent")}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
>
|
||||
<option value="agent">Агент</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{status.configured ? "Обновить" : "Подключить"}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button type="button" onClick={handleDisable} disabled={loading} className="btn btn-ghost">
|
||||
Отключить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { getLdapStatus } from "@/lib/auth/ldap-config";
|
||||
import { LdapSettingsForm } from "./ldap-settings-form";
|
||||
|
||||
export default async function LdapSettingsPage() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session || session.user.role !== "admin") {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const status = await getLdapStatus();
|
||||
|
||||
return (
|
||||
<div className="max-w-lg">
|
||||
<h1 className="mb-1 text-xl font-bold tracking-tight">LDAP</h1>
|
||||
<p className="mb-6 text-sm text-text-muted">
|
||||
Подключите каталог LDAP/AD — вход будет сначала пробовать LDAP, а при неудаче откатываться на локальный
|
||||
пароль. Пока LDAP не подключён, локальный вход работает как обычно.
|
||||
</p>
|
||||
<LdapSettingsForm initialStatus={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
|
||||
if (!user) {
|
||||
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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireAdminSession } from "@/lib/auth/require";
|
||||
import { getLdapStatus, saveLdapSettings, disableLdap } from "@/lib/auth/ldap-config";
|
||||
import { testLdapBind } from "@/lib/ldap/client";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json(await getLdapStatus());
|
||||
}
|
||||
|
||||
const configureSchema = z.object({
|
||||
host: z.string().min(1),
|
||||
port: z.coerce.number().int().positive(),
|
||||
useTls: z.boolean(),
|
||||
bindDn: z.string().min(1),
|
||||
bindPassword: z.string().min(1),
|
||||
baseDn: z.string().min(1),
|
||||
userFilter: z.string().min(1),
|
||||
listFilter: z.string().min(1),
|
||||
defaultRole: z.enum(["admin", "agent"]),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = configureSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await testLdapBind(parsed.data);
|
||||
await saveLdapSettings(parsed.data);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
await disableLdap();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireAdminSession } from "@/lib/auth/require";
|
||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||
import { searchLdapDirectory } from "@/lib/ldap/client";
|
||||
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
|
||||
|
||||
const importSchema = z.object({ emails: z.array(z.string().email()).min(1) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const settings = await getLdapSettings();
|
||||
if (!settings) {
|
||||
return NextResponse.json({ error: "LDAP не настроен" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = importSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await searchLdapDirectory();
|
||||
const wanted = new Set(parsed.data.emails.map((e) => e.toLowerCase()));
|
||||
const toImport = entries.filter((e) => wanted.has(e.email.toLowerCase()));
|
||||
|
||||
const imported = [];
|
||||
for (const entry of toImport) {
|
||||
const user = await findOrCreateUserFromLdap({
|
||||
email: entry.email,
|
||||
name: entry.name,
|
||||
defaultRole: settings.defaultRole,
|
||||
});
|
||||
imported.push(user.email);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, imported });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Не удалось импортировать: ${message}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdminSession } from "@/lib/auth/require";
|
||||
import { getLdapSettings } from "@/lib/auth/ldap-config";
|
||||
import { searchLdapDirectory } from "@/lib/ldap/client";
|
||||
import { listUsers } from "@/lib/auth/users";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const settings = await getLdapSettings();
|
||||
if (!settings) {
|
||||
return NextResponse.json({ configured: false, entries: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const [entries, existing] = await Promise.all([searchLdapDirectory(), listUsers()]);
|
||||
const existingEmails = new Set(existing.map((u) => u.email.toLowerCase()));
|
||||
const fresh = entries.filter((e) => !existingEmails.has(e.email.toLowerCase()));
|
||||
return NextResponse.json({ configured: true, entries: fresh });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Не удалось получить список из LDAP: ${message}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireAdminSession } from "@/lib/auth/require";
|
||||
import { updateUserRole, deleteUser } from "@/lib/auth/users";
|
||||
|
||||
const patchSchema = z.object({ role: z.enum(["admin", "agent"]) });
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await updateUserRole(id, parsed.data.role);
|
||||
return NextResponse.json({ user });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Не удалось изменить роль";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const { id } = await params;
|
||||
if (id === session.user.id) {
|
||||
return NextResponse.json({ error: "Нельзя удалить собственный аккаунт" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUser(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Не удалось удалить аккаунт";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireAdminSession } from "@/lib/auth/require";
|
||||
import { listUsers, createLocalUser } from "@/lib/auth/users";
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
return NextResponse.json({ users: await listUsers() });
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
|
||||
role: z.enum(["admin", "agent"]),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session, response } = await requireAdminSession();
|
||||
if (!session) return response;
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await createLocalUser(parsed.data);
|
||||
return NextResponse.json({ user }, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user