Add account management and LDAP login

Admins can now create local agent/admin accounts and configure LDAP
directly from the UI (Настройки → Аккаунты / LDAP), with login trying
LDAP first and falling back to the local password. This is the first
place users.role is actually enforced (requireAdminSession).

Fixed a bug in the generated 0005 migration: the INSERT into __new_users
selected auth_source from the old users table, which doesn't have that
column yet — caused drizzle-kit migrate to fail silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-27 08:56:08 +00:00
1 parent 8121d8aa51
commit cb12711e91
23 files changed
+2379 -13

No files matched your search

+1 -1
View File
@@ -27,7 +27,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<div className="ml-auto flex items-center gap-2 sm:gap-3">
<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>
+13 -10
View File
@@ -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>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { getLdapStatus } from "@/lib/auth/ldap-config";
import { LdapSettingsForm } from "./ldap-settings-form";
export default async function LdapSettingsPage() {
const session = await getCurrentSession();
if (!session || session.user.role !== "admin") {
redirect("/dashboard");
}
const status = await getLdapStatus();
return (
<div className="max-w-lg">
<h1 className="mb-1 text-xl font-bold tracking-tight">LDAP</h1>
<p className="mb-6 text-sm text-text-muted">
Подключите каталог LDAP/AD — вход будет сначала пробовать LDAP, а при неудаче откатываться на локальный
пароль. Пока LDAP не подключён, локальный вход работает как обычно.
</p>
<LdapSettingsForm initialStatus={status} />
</div>
);
}
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
if (!user) {
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) {
+23 -1
View File
@@ -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 });
}
+54
View File
@@ -0,0 +1,54 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapStatus, saveLdapSettings, disableLdap } from "@/lib/auth/ldap-config";
import { testLdapBind } from "@/lib/ldap/client";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
return NextResponse.json(await getLdapStatus());
}
const configureSchema = z.object({
host: z.string().min(1),
port: z.coerce.number().int().positive(),
useTls: z.boolean(),
bindDn: z.string().min(1),
bindPassword: z.string().min(1),
baseDn: z.string().min(1),
userFilter: z.string().min(1),
listFilter: z.string().min(1),
defaultRole: z.enum(["admin", "agent"]),
});
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = configureSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
await testLdapBind(parsed.data);
await saveLdapSettings(parsed.data);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось подключиться: ${message}` }, { status: 400 });
}
}
export async function DELETE() {
const { session, response } = await requireAdminSession();
if (!session) return response;
await disableLdap();
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,47 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { searchLdapDirectory } from "@/lib/ldap/client";
import { findOrCreateUserFromLdap } from "@/lib/auth/users";
const importSchema = z.object({ emails: z.array(z.string().email()).min(1) });
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const settings = await getLdapSettings();
if (!settings) {
return NextResponse.json({ error: "LDAP не настроен" }, { status: 400 });
}
const body = await request.json().catch(() => null);
const parsed = importSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const entries = await searchLdapDirectory();
const wanted = new Set(parsed.data.emails.map((e) => e.toLowerCase()));
const toImport = entries.filter((e) => wanted.has(e.email.toLowerCase()));
const imported = [];
for (const entry of toImport) {
const user = await findOrCreateUserFromLdap({
email: entry.email,
name: entry.name,
defaultRole: settings.defaultRole,
});
imported.push(user.email);
}
return NextResponse.json({ ok: true, imported });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось импортировать: ${message}` }, { status: 502 });
}
}
+27
View File
@@ -0,0 +1,27 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { requireAdminSession } from "@/lib/auth/require";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { searchLdapDirectory } from "@/lib/ldap/client";
import { listUsers } from "@/lib/auth/users";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
const settings = await getLdapSettings();
if (!settings) {
return NextResponse.json({ configured: false, entries: [] });
}
try {
const [entries, existing] = await Promise.all([searchLdapDirectory(), listUsers()]);
const existingEmails = new Set(existing.map((u) => u.email.toLowerCase()));
const fresh = entries.filter((e) => !existingEmails.has(e.email.toLowerCase()));
return NextResponse.json({ configured: true, entries: fresh });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: `Не удалось получить список из LDAP: ${message}` }, { status: 502 });
}
}
+46
View File
@@ -0,0 +1,46 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { updateUserRole, deleteUser } from "@/lib/auth/users";
const patchSchema = z.object({ role: z.enum(["admin", "agent"]) });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const user = await updateUserRole(id, parsed.data.role);
return NextResponse.json({ user });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось изменить роль";
return NextResponse.json({ error: message }, { status: 400 });
}
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const { id } = await params;
if (id === session.user.id) {
return NextResponse.json({ error: "Нельзя удалить собственный аккаунт" }, { status: 400 });
}
try {
await deleteUser(id);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : "Не удалось удалить аккаунт";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+38
View File
@@ -0,0 +1,38 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/auth/require";
import { listUsers, createLocalUser } from "@/lib/auth/users";
export async function GET() {
const { session, response } = await requireAdminSession();
if (!session) return response;
return NextResponse.json({ users: await listUsers() });
}
const createSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8, "Пароль должен быть не короче 8 символов"),
role: z.enum(["admin", "agent"]),
});
export async function POST(request: Request) {
const { session, response } = await requireAdminSession();
if (!session) return response;
const body = await request.json().catch(() => null);
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
try {
const user = await createLocalUser(parsed.data);
return NextResponse.json({ user }, { status: 201 });
} catch {
return NextResponse.json({ error: "Такой email уже используется" }, { status: 409 });
}
}