Add LDAP login for the customer portal as a backup to the personal link

Customers now have a second way in besides their email/Telegram link:
/portal-login, authenticating against the same LDAP directory used for
staff. On success it looks up (or creates) their customer record by
email — reusing findOrCreateCustomerByEmail, the same JIT pattern
already used for the email channel — so a lost link never strands them
as long as their LDAP account still resolves to the same email.

No local password for customers (LDAP only) — the personal link stays
the primary path, this is just resilience if it's lost or Telegram gets
blocked.

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-31 16:43:29 +00:00
1 parent cb12711e91
commit 904f09ff09
2 files changed
+152

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { LogIn } from "lucide-react";
export default function PortalLoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/portal/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(data?.error ?? "Не удалось войти");
setLoading(false);
return;
}
router.push(data.portalUrl);
}
return (
<motion.form
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut" }}
className="card mx-auto max-w-sm p-8"
>
<h1 className="mb-1 font-display text-xl font-semibold">Вход по учётной записи</h1>
<p className="mb-6 text-sm text-text-muted">
Если вы потеряли персональную ссылку из письма или Telegram, войдите с рабочим логином и паролем — заявки
привяжутся к вашей учётной записи.
</p>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Email</span>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
autoFocus
/>
</label>
<label className="mb-5 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Пароль</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mb-4 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text"
>
{error}
</motion.p>
)}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
<LogIn size={16} />
{loading ? "Входим…" : "Войти"}
</button>
</motion.form>
);
}
+63
View File
@@ -0,0 +1,63 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { authenticateLdapUser } from "@/lib/ldap/client";
import { getLdapSettings } from "@/lib/auth/ldap-config";
import { findOrCreateCustomerByEmail } from "@/lib/tickets/service";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — mirrors /api/auth/login.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
// Customer portal access has no local password — LDAP is the only login
// method here (the personal link stays the primary path). Unlike the admin
// login, we surface "LDAP isn't set up" as its own message: with no local
// fallback to quietly degrade to, a generic "invalid credentials" would be
// actively misleading while LDAP is disabled.
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Слишком много попыток — попробуйте позже" }, { status: 429 });
}
const ldapSettings = await getLdapSettings();
if (!ldapSettings) {
return NextResponse.json(
{ error: "Вход по LDAP пока недоступен. Используйте ссылку из письма или Telegram." },
{ status: 400 },
);
}
const ldapUser = await authenticateLdapUser(email, parsed.data.password);
if (!ldapUser) {
return NextResponse.json({ error: "Неверный email или пароль" }, { status: 401 });
}
const customer = await findOrCreateCustomerByEmail(ldapUser.email, ldapUser.name);
return NextResponse.json({ ok: true, portalUrl: `/t/${customer.portalToken}` });
}