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

@@ -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 });
}
}