From 904f09ff09688d91a0956d9a0040f0eea1e0231d Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 31 Jul 2026 16:43:29 +0000 Subject: [PATCH] Add LDAP login for the customer portal as a backup to the personal link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH --- src/app/(portal)/portal-login/page.tsx | 89 ++++++++++++++++++++++++++ src/app/api/portal/auth/login/route.ts | 63 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/app/(portal)/portal-login/page.tsx create mode 100644 src/app/api/portal/auth/login/route.ts diff --git a/src/app/(portal)/portal-login/page.tsx b/src/app/(portal)/portal-login/page.tsx new file mode 100644 index 0000000..f8bf461 --- /dev/null +++ b/src/app/(portal)/portal-login/page.tsx @@ -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(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 ( + +

Вход по учётной записи

+

+ Если вы потеряли персональную ссылку из письма или Telegram, войдите с рабочим логином и паролем — заявки + привяжутся к вашей учётной записи. +

+ + + + + + {error && ( + + {error} + + )} + + +
+ ); +} diff --git a/src/app/api/portal/auth/login/route.ts b/src/app/api/portal/auth/login/route.ts new file mode 100644 index 0000000..95317da --- /dev/null +++ b/src/app/api/portal/auth/login/route.ts @@ -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(); +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}` }); +}