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:
1 parent
cb12711e91
commit
904f09ff09
2 files changed
+152
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}` });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user