lovart3/app/(auth)/login/page.tsx
2025-12-18 22:12:03 +08:00

572 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useActionState, useEffect, useState, useRef, useCallback } from "react";
import { useTheme } from "next-themes";
import { Moon, Sun, X } from "lucide-react";
import { signIn } from "next-auth/react"; // Import signIn
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { type LoginActionState, login } from "../actions";
import { Button } from "@/components/ui/button";
import { AntigravityBackground } from "@/components/antigravity-background";
export default function Page() {
const [email, setEmail] = useState("");
const [isSuccessful, setIsSuccessful] = useState(false);
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [siteLogoDark, setSiteLogoDark] = useState<string | null>(null);
const [isDingTalkEnabled, setIsDingTalkEnabled] = useState(false);
const [dingTalkAppKey, setDingTalkAppKey] = useState<string | null>(null);
const [dingTalkOrganizations, setDingTalkOrganizations] = useState<string[]>([]);
const [isRegistrationEnabled, setIsRegistrationEnabled] = useState(true);
const [showDingTalkModal, setShowDingTalkModal] = useState(false);
const [dingTalkIframeSrc, setDingTalkIframeSrc] = useState("");
const [showOrgMenu, setShowOrgMenu] = useState(false);
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [isHandlingToken, setIsHandlingToken] = useState(false);
// 系统通知状态
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
const [showNotification, setShowNotification] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
// 声明 formAction 在前面
const [state, formAction] = useActionState<LoginActionState, FormData>(
login,
{
status: "idle",
}
);
// 使用 token 完成钉钉登录
const completeDingTalkLogin = useCallback(async (token: string) => {
console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "...");
setIsHandlingToken(true);
try {
// 获取 CSRF token
const csrfRes = await fetch("/api/auth/csrf");
const { csrfToken } = await csrfRes.json();
console.log("[DingTalk] Got CSRF token");
// 手动构造登录请求
const loginRes = await fetch("/api/auth/callback/credentials", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
csrfToken,
token,
callbackUrl: "/",
}),
credentials: "include", // 重要:确保 cookie 被发送和接收
});
console.log("[DingTalk] Login response status:", loginRes.status);
console.log("[DingTalk] Login response headers:", Object.fromEntries(loginRes.headers.entries()));
// 检查响应类型
const contentType = loginRes.headers.get("content-type");
console.log("[DingTalk] Content-Type:", contentType);
if (loginRes.redirected || loginRes.status === 302) {
// 登录成功,被重定向
console.log("[DingTalk] Login successful (redirected to:", loginRes.url, ")");
// 等待一小段时间确保 cookie 被设置
await new Promise(resolve => setTimeout(resolve, 300));
// 验证 session 是否创建成功
const sessionRes = await fetch("/api/auth/session");
const session = await sessionRes.json();
console.log("[DingTalk] Session after login:", session);
if (session && session.user) {
console.log("[DingTalk] Session validated, redirecting to home...");
setIsSuccessful(true);
window.location.replace("/");
} else {
console.error("[DingTalk] Session not created after login");
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:无法创建会话" });
}
} else if (contentType?.includes("application/json")) {
const result = await loginRes.json();
console.log("[DingTalk] Login result:", result);
if (result.url || loginRes.ok) {
// 登录成功
console.log("[DingTalk] Login successful");
// 等待一小段时间确保 cookie 被设置
await new Promise(resolve => setTimeout(resolve, 300));
// 验证 session
const sessionRes = await fetch("/api/auth/session");
const session = await sessionRes.json();
console.log("[DingTalk] Session after login:", session);
if (session && session.user) {
console.log("[DingTalk] Session validated, redirecting...");
setIsSuccessful(true);
window.location.replace(result.url || "/");
} else {
console.error("[DingTalk] Session not created");
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:无法创建会话" });
}
} else {
// 登录失败
console.error("[DingTalk] Login failed:", result);
setIsHandlingToken(false);
toast({ type: "error", description: result.error || "登录失败,请重试" });
}
} else {
// 未知响应
const responseText = await loginRes.text();
console.error("[DingTalk] Unexpected response type. Body:", responseText.substring(0, 200));
setIsHandlingToken(false);
toast({ type: "error", description: "登录失败:服务器响应异常" });
}
} catch (error) {
console.error("[DingTalk] Login error:", error);
setIsHandlingToken(false);
toast({ type: "error", description: "登录发生错误:" + (error instanceof Error ? error.message : String(error)) });
}
}, []);
// Chunk 加载错误自动恢复
useEffect(() => {
const handleChunkError = (event: ErrorEvent) => {
const target = event.target as any;
if (target?.src?.includes("/_next/static/chunks/") || event.message?.includes("Failed to load chunk")) {
console.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
// 3秒后自动刷新页面(清除缓存)
setTimeout(() => {
window.location.reload();
}, 3000);
}
};
window.addEventListener("error", handleChunkError, true);
return () => window.removeEventListener("error", handleChunkError, true);
}, []);
// 初始化设置
useEffect(() => {
setMounted(true);
// 测试标记 - 确认代码已更新
console.log("=".repeat(50));
console.log("🔔 登录页面代码版本: 2024-12-10 v3.0");
console.log("=".repeat(50));
// 获取网站设置
fetch("/api/site-settings")
.then((res) => res.json())
.then((data) => {
console.log("[Login] Site settings data:", data);
if (data.siteName) setSiteName(data.siteName);
if (data.siteLogo) setSiteLogo(data.siteLogo);
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
// 处理系统通知
console.log("[Login] Notification data:", data.notification);
if (data.notification && data.notification.enabled) {
console.log("[Login] Setting notification and showing dialog");
setNotification(data.notification);
// 每次访问登录页都显示通知(重要公告)
setShowNotification(true);
} else {
console.log("[Login] Notification not enabled or missing");
}
})
.catch((err) => {
console.error("[Login] Failed to fetch site settings:", err);
});
// 获取认证设置
fetch("/api/auth-settings")
.then((res) => res.json())
.then((data) => {
if (data.isDingTalkEnabled) setIsDingTalkEnabled(true);
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
if (Array.isArray(data.organizations)) {
setDingTalkOrganizations(
data.organizations
.map((org: { name?: string }) => org.name)
.filter((name: string | undefined): name is string => !!name),
);
}
if (data.isRegistrationEnabled !== undefined) {
setIsRegistrationEnabled(data.isRegistrationEnabled);
}
})
.catch(console.error);
}, []);
// 检查 URL 中是否有钉钉 Token(页面加载时自动登录)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("dingtalk_token");
if (token) {
completeDingTalkLogin(token);
}
}, [completeDingTalkLogin]);
// 监听来自钉钉回调页面的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
console.log("[DingTalk] Received message:", event.data);
if (event.data && event.data.type === "dingtalk_login_success") {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
// 使用返回的 token 完成 Auth.js 登录
if (event.data.token) {
toast({ type: "success", description: "钉钉认证成功,正在登录..." });
completeDingTalkLogin(event.data.token);
} else {
toast({ type: "error", description: "钉钉登录失败:未获取到登录凭证" });
}
} else if (event.data && event.data.type === "dingtalk_login_error") {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
toast({ type: "error", description: "钉钉登录失败:" + (event.data.message || "未知错误") });
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [completeDingTalkLogin]);
// 处理登录状态变化
useEffect(() => {
if (state.status === "failed") {
setIsHandlingToken(false);
toast({
type: "error",
description: "邮箱或密码不正确",
});
} else if (state.status === "invalid_data") {
setIsHandlingToken(false);
toast({
type: "error",
description: "提交的信息校验失败",
});
} else if (state.status === "success") {
setIsSuccessful(true);
window.location.href = "/";
}
}, [state.status]);
const handleSubmit = (formData: FormData) => {
setEmail(formData.get("email") as string);
formAction(formData);
};
// 根据组织名称启动钉钉登录(在 iframe 中打开)
const startDingTalkLoginForOrg = (orgName: string) => {
// 清除 URL 中的 token 参数,避免干扰
if (
typeof window !== "undefined" &&
window.location.search.includes("dingtalk_token")
) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
orgName,
)}`;
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
setDingTalkIframeSrc(loginUrl);
setShowDingTalkModal(true);
setShowOrgMenu(false);
};
// 钉钉登录 - 支持多组织
const handleDingTalkLogin = () => {
if (!isDingTalkEnabled) {
toast({ type: "error", description: "钉钉登录未启用" });
return;
}
// 没有配置多组织:回退到单一 appKey 流程
if (dingTalkOrganizations.length === 0) {
if (!dingTalkAppKey) {
toast({ type: "error", description: "钉钉登录未正确配置" });
return;
}
// 兼容:直接使用旧的方式在 iframe 中打开
if (
typeof window === "undefined" ||
typeof window.location === "undefined"
) {
toast({ type: "error", description: "当前环境不支持钉钉登录" });
return;
}
// 清除 URL 中的 token 参数,避免干扰
if (window.location.search.includes("dingtalk_token")) {
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
const param = {
redirect_uri: encodeURIComponent(
window.location.origin + "/dingtalk-callback.html",
),
client_id: dingTalkAppKey,
scope: "openid",
response_type: "code",
state: "default",
prompt: "consent",
};
const redirectUrl =
"https://login.dingtalk.com/oauth2/auth?" +
Object.keys(param)
.map((key) => key + "=" + (param as any)[key])
.join("&");
console.log("[DingTalk] Opening OAuth URL (legacy):", redirectUrl);
setDingTalkIframeSrc(redirectUrl);
setShowDingTalkModal(true);
return;
}
// 只有一个组织:直接使用该组织登录
if (dingTalkOrganizations.length === 1) {
startDingTalkLoginForOrg(dingTalkOrganizations[0] as string);
return;
}
// 多组织:点击时展开/收起选择菜单(hover 时也会展开)
setShowOrgMenu((prev) => !prev);
};
// 关闭钉钉登录弹窗
const closeDingTalkModal = () => {
setShowDingTalkModal(false);
setDingTalkIframeSrc("");
};
// 决定显示哪个 LOGO
const displayLogo = mounted
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
: siteLogo;
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0 relative overflow-hidden">
{/* Antigravity 动画背景 */}
<AntigravityBackground />
{/* 主题切换按钮 */}
<div className="absolute top-4 right-4 z-10">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
>
{mounted && resolvedTheme === "dark" ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
</div>
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl relative z-10 pointer-events-auto">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16 backdrop-blur-sm bg-background/80 rounded-2xl p-6 pointer-events-auto">
{displayLogo ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={displayLogo} alt={siteName} className="h-12 w-auto mb-2 object-contain" />
) : null}
<h3 className="font-semibold text-xl dark:text-zinc-50">登录 {siteName}</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
使用邮箱与密码进入系统
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email} className="pointer-events-auto">
<SubmitButton isSuccessful={isSuccessful}>登录</SubmitButton>
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) && (
<div className="mt-4 w-full relative">
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
或使用第三方登录
</span>
</div>
</div>
<button
type="button"
onClick={handleDingTalkLogin}
className="flex w-full items-center justify-center gap-2 rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<svg style={{ width: "20px", height: "20px" }} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z"
fill="#3296FA"
/>
</svg>
钉钉登录
</button>
{/* 多组织下的小型选择框(悬浮在按钮附近) */}
{dingTalkOrganizations.length > 1 && showOrgMenu && (
<div className="absolute left-0 right-0 -top-2 -translate-y-full rounded-md border bg-background shadow-lg z-20 max-h-60 overflow-auto text-sm">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => startDingTalkLoginForOrg(org)}
className="flex w-full items-center justify-between px-3 py-2 text-left hover:bg-muted"
>
<span>{org}</span>
</button>
))}
</div>
)}
</div>
)}
{isRegistrationEnabled && (
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
还没有账号?
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/register"
>
立即注册
</Link>
,即可免费体验。
</p>
)}
</AuthForm>
</div>
{/* 钉钉登录 iframe 弹窗 / 多组织选择 */}
{showDingTalkModal && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) closeDingTalkModal();
}}
>
<div className="w-[90%] max-w-[500px] h-[720px] bg-white dark:bg-zinc-900 rounded-xl overflow-hidden flex flex-col shadow-2xl">
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-zinc-800 border-b dark:border-zinc-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
钉钉登录
</h3>
<button
onClick={closeDingTalkModal}
className="p-2 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 多组织选择 */}
{dingTalkOrganizations.length > 1 && !dingTalkIframeSrc && (
<div className="p-4 space-y-3">
<p className="text-sm text-muted-foreground">
请选择要使用的钉钉组织进行登录:
</p>
<div className="space-y-2">
{dingTalkOrganizations.map((org) => (
<button
key={org}
type="button"
onClick={() => {
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
org,
)}`;
console.log(
"[DingTalk] Selected organization:",
org,
loginUrl,
);
setDingTalkIframeSrc(loginUrl);
}}
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span>{org}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
选择后将在下方加载钉钉登录页面。
</p>
</div>
)}
{/* 登录 iframe */}
{dingTalkIframeSrc && (
<iframe
ref={iframeRef}
src={dingTalkIframeSrc}
className="flex-1 w-full border-0"
title="钉钉登录"
/>
)}
<div className="p-4 bg-gray-50 dark:bg-zinc-800 border-t dark:border-zinc-700 flex justify-center">
<button
onClick={closeDingTalkModal}
className="px-4 py-2 bg-gray-200 dark:bg-zinc-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-zinc-600 transition-colors"
>
返回登录
</button>
</div>
</div>
</div>
)}
{/* 系统通知弹窗 */}
<Dialog open={showNotification} onOpenChange={setShowNotification}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{notification?.title || "系统通知"}</DialogTitle>
</DialogHeader>
<div className="whitespace-pre-wrap text-sm text-muted-foreground mt-4 max-h-[60vh] overflow-y-auto">
{notification?.content}
</div>
</DialogContent>
</Dialog>
{/* 登录中 Loading 状态 */}
{isHandlingToken && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm">
<div className="bg-white dark:bg-zinc-800 p-8 rounded-xl shadow-2xl flex flex-col items-center">
<div className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-lg font-medium text-gray-900 dark:text-white">正在完成登录...</p>
</div>
</div>
)}
</div>
);
}