726 lines
28 KiB
TypeScript
726 lines
28 KiB
TypeScript
"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";
|
||
import { createClientLogger } from "@/lib/observability/client-logger";
|
||
|
||
const logger = createClientLogger("auth-login");
|
||
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 [isGoogleEnabled, setIsGoogleEnabled] = useState(false);
|
||
const [isGoogleConfigured, setIsGoogleConfigured] = 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 [isCheckingExistingSession, setIsCheckingExistingSession] = useState(true);
|
||
|
||
// 系统通知状态
|
||
const [notification, setNotification] = useState<{ enabled: boolean; title: string; content: string } | null>(null);
|
||
const [showNotification, setShowNotification] = useState(false);
|
||
const loginRedirectTimerRef = useRef<number | null>(null);
|
||
const loginRedirectFallbackTimerRef = useRef<number | null>(null);
|
||
|
||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||
|
||
// 声明 formAction 在前面
|
||
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||
login,
|
||
{
|
||
status: "idle",
|
||
}
|
||
);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
|
||
fetch("/api/auth/validate-session", {
|
||
cache: "no-store",
|
||
credentials: "include",
|
||
})
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
if (cancelled) return;
|
||
if (data?.valid === true && data?.userType !== "guest") {
|
||
window.location.replace("/");
|
||
return;
|
||
}
|
||
setIsCheckingExistingSession(false);
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setIsCheckingExistingSession(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
// 使用 token 完成钉钉登录
|
||
const completeDingTalkLogin = useCallback(async (token: string) => {
|
||
logger.debug("[DingTalk] Completing login with token", { hasToken: Boolean(token) });
|
||
setIsHandlingToken(true);
|
||
|
||
try {
|
||
// 获取 CSRF token
|
||
const csrfRes = await fetch("/api/auth/csrf");
|
||
const { csrfToken } = await csrfRes.json();
|
||
logger.debug("[DingTalk] CSRF token resolved");
|
||
|
||
// 手动构造登录请求
|
||
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 被发送和接收
|
||
});
|
||
|
||
// 检查响应类型
|
||
const contentType = loginRes.headers.get("content-type");
|
||
logger.debug("[DingTalk] Login response received", {
|
||
status: loginRes.status,
|
||
redirected: loginRes.redirected,
|
||
contentType,
|
||
});
|
||
|
||
if (loginRes.redirected || loginRes.status === 302) {
|
||
// 登录成功,被重定向
|
||
logger.debug("[DingTalk] Login redirected successfully");
|
||
|
||
// 等待一小段时间确保 cookie 被设置
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
// 验证 session 是否创建成功
|
||
const sessionRes = await fetch("/api/auth/session");
|
||
const session = await sessionRes.json();
|
||
logger.debug("[DingTalk] Session validation completed", {
|
||
hasUser: Boolean(session?.user),
|
||
});
|
||
|
||
if (session && session.user) {
|
||
logger.debug("[DingTalk] Session validated, redirecting to home...");
|
||
setIsSuccessful(true);
|
||
// 使用 replace 而不是 href,避免在历史记录中留下登录页
|
||
// 增加短暂延迟确保 cookie 完全同步
|
||
setTimeout(() => {
|
||
window.location.replace("/");
|
||
}, 200);
|
||
} else {
|
||
logger.error("[DingTalk] Session not created after login");
|
||
setIsHandlingToken(false);
|
||
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||
}
|
||
} else if (contentType?.includes("application/json")) {
|
||
const result = await loginRes.json();
|
||
logger.debug("[DingTalk] Login JSON response received", {
|
||
ok: loginRes.ok,
|
||
hasUrl: Boolean(result?.url),
|
||
hasError: Boolean(result?.error),
|
||
});
|
||
|
||
if (result.url || loginRes.ok) {
|
||
// 登录成功
|
||
logger.debug("[DingTalk] Login successful");
|
||
|
||
// 等待一小段时间确保 cookie 被设置
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
// 验证 session
|
||
const sessionRes = await fetch("/api/auth/session");
|
||
const session = await sessionRes.json();
|
||
logger.debug("[DingTalk] Session validation completed", {
|
||
hasUser: Boolean(session?.user),
|
||
});
|
||
|
||
if (session && session.user) {
|
||
logger.debug("[DingTalk] Session validated, redirecting...");
|
||
setIsSuccessful(true);
|
||
// 使用 replace 而不是 href,避免在历史记录中留下登录页
|
||
// 增加短暂延迟确保 cookie 完全同步
|
||
setTimeout(() => {
|
||
window.location.replace(result.url || "/");
|
||
}, 200);
|
||
} else {
|
||
logger.error("[DingTalk] Session not created");
|
||
setIsHandlingToken(false);
|
||
toast({ type: "error", description: "登录失败:无法创建会话" });
|
||
}
|
||
} else {
|
||
// 登录失败
|
||
logger.error("[DingTalk] Login failed", {
|
||
hasError: Boolean(result?.error),
|
||
});
|
||
setIsHandlingToken(false);
|
||
toast({ type: "error", description: result.error || "登录失败,请重试" });
|
||
}
|
||
} else {
|
||
// 未知响应
|
||
await loginRes.text();
|
||
logger.error("[DingTalk] Unexpected response type", {
|
||
status: loginRes.status,
|
||
contentType,
|
||
});
|
||
setIsHandlingToken(false);
|
||
toast({ type: "error", description: "登录失败:服务器响应异常" });
|
||
}
|
||
} catch (error) {
|
||
logger.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")) {
|
||
logger.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面");
|
||
// 3秒后自动刷新页面(清除缓存)
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 3000);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("error", handleChunkError, true);
|
||
return () => window.removeEventListener("error", handleChunkError, true);
|
||
}, []);
|
||
|
||
// 初始化设置
|
||
useEffect(() => {
|
||
setMounted(true);
|
||
|
||
// 获取网站设置(添加时间戳防止缓存)
|
||
fetch(`/api/site-settings?scope=auth&t=${Date.now()}`)
|
||
.then((res) => res.json())
|
||
.then((data) => {
|
||
logger.debug("[Login] Site settings loaded", {
|
||
hasSiteName: Boolean(data.siteName),
|
||
hasSiteLogo: Boolean(data.siteLogo),
|
||
hasNotification: Boolean(data.notification),
|
||
});
|
||
if (data.siteName) setSiteName(data.siteName);
|
||
if (data.siteLogo) setSiteLogo(data.siteLogo);
|
||
if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark);
|
||
|
||
// 处理系统通知
|
||
if (data.notification && data.notification.enabled) {
|
||
logger.debug("[Login] Setting notification and showing dialog");
|
||
setNotification(data.notification);
|
||
// 每次访问登录页都显示通知(重要公告)
|
||
setShowNotification(true);
|
||
} else {
|
||
logger.debug("[Login] Notification not enabled or missing");
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
logger.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.isGoogleEnabled) setIsGoogleEnabled(true);
|
||
if (data.isGoogleConfigured) setIsGoogleConfigured(true);
|
||
if (data.dingTalkAppKey) setDingTalkAppKey(data.dingTalkAppKey);
|
||
const rawOrganizations = Array.isArray(data.organizations)
|
||
? data.organizations
|
||
: Array.isArray(data.dingTalkOrganizations)
|
||
? data.dingTalkOrganizations
|
||
: [];
|
||
|
||
if (rawOrganizations.length > 0) {
|
||
setDingTalkOrganizations(
|
||
rawOrganizations
|
||
.map((org: { name?: string } | string) =>
|
||
typeof org === "string" ? org : org.name,
|
||
)
|
||
.filter((name: string | undefined): name is string => Boolean(name?.trim()))
|
||
.map((name: string) => name.trim()),
|
||
);
|
||
}
|
||
if (data.isRegistrationEnabled !== undefined) {
|
||
setIsRegistrationEnabled(data.isRegistrationEnabled);
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
logger.error("[Login] Failed to fetch auth settings", error);
|
||
});
|
||
}, []);
|
||
|
||
const handleGoogleLogin = async () => {
|
||
if (!isGoogleEnabled) {
|
||
toast({ type: "error", description: "Google 登录未启用" });
|
||
return;
|
||
}
|
||
|
||
if (!isGoogleConfigured) {
|
||
toast({ type: "error", description: "Google 登录未完成服务器配置" });
|
||
return;
|
||
}
|
||
|
||
await signIn("google", { callbackUrl: "/" });
|
||
};
|
||
|
||
// 检查 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) => {
|
||
logger.debug("[DingTalk] Received message", {
|
||
type: event.data?.type,
|
||
hasToken: Boolean(event.data?.token),
|
||
});
|
||
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 (loginRedirectTimerRef.current) {
|
||
window.clearTimeout(loginRedirectTimerRef.current);
|
||
loginRedirectTimerRef.current = null;
|
||
}
|
||
if (loginRedirectFallbackTimerRef.current) {
|
||
window.clearTimeout(loginRedirectFallbackTimerRef.current);
|
||
loginRedirectFallbackTimerRef.current = null;
|
||
}
|
||
|
||
if (state.status === "failed") {
|
||
setIsSuccessful(false);
|
||
setIsHandlingToken(false);
|
||
toast({
|
||
type: "error",
|
||
description: "邮箱或密码不正确",
|
||
});
|
||
} else if (state.status === "invalid_data") {
|
||
setIsSuccessful(false);
|
||
setIsHandlingToken(false);
|
||
toast({
|
||
type: "error",
|
||
description: "提交的信息校验失败",
|
||
});
|
||
} else if (state.status === "success") {
|
||
toast({ type: "success", description: "登录成功,正在进入首页" });
|
||
setIsSuccessful(true);
|
||
loginRedirectTimerRef.current = window.setTimeout(() => {
|
||
window.location.replace("/");
|
||
}, 120);
|
||
loginRedirectFallbackTimerRef.current = window.setTimeout(() => {
|
||
window.location.assign("/");
|
||
}, 1600);
|
||
}
|
||
}, [state.status]);
|
||
|
||
const handleSubmit = (formData: FormData) => {
|
||
setIsSuccessful(false);
|
||
setEmail(formData.get("email") as string);
|
||
formAction(formData);
|
||
};
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (loginRedirectTimerRef.current) {
|
||
window.clearTimeout(loginRedirectTimerRef.current);
|
||
}
|
||
if (loginRedirectFallbackTimerRef.current) {
|
||
window.clearTimeout(loginRedirectFallbackTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
const getSafeLoginCallbackUrl = () => {
|
||
if (typeof window === "undefined") return "/";
|
||
|
||
const callbackUrl = new URLSearchParams(window.location.search).get(
|
||
"callbackUrl",
|
||
);
|
||
if (
|
||
callbackUrl &&
|
||
callbackUrl.startsWith("/") &&
|
||
!callbackUrl.startsWith("//")
|
||
) {
|
||
return callbackUrl;
|
||
}
|
||
|
||
return "/";
|
||
};
|
||
|
||
const buildDingTalkLoginUrl = (orgName?: string) => {
|
||
const params = new URLSearchParams();
|
||
if (orgName) params.set("org", orgName);
|
||
|
||
const callbackUrl = getSafeLoginCallbackUrl();
|
||
if (callbackUrl !== "/") {
|
||
params.set("callbackUrl", callbackUrl);
|
||
}
|
||
|
||
const query = params.toString();
|
||
return `/api/auth/dingtalk/login${query ? `?${query}` : ""}`;
|
||
};
|
||
|
||
// 根据组织名称启动钉钉登录。钉钉授权页禁止第三方 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 = buildDingTalkLoginUrl(orgName);
|
||
|
||
logger.debug("[DingTalk] Redirecting org login", {
|
||
hasOrg: Boolean(orgName),
|
||
loginUrl,
|
||
});
|
||
setShowDingTalkModal(true);
|
||
setShowOrgMenu(false);
|
||
window.location.assign(loginUrl);
|
||
};
|
||
|
||
// 钉钉登录 - 支持多组织
|
||
const handleDingTalkLogin = () => {
|
||
if (!isDingTalkEnabled) {
|
||
toast({ type: "error", description: "钉钉登录未启用" });
|
||
return;
|
||
}
|
||
|
||
// 没有配置多组织:回退到单一 appKey 流程
|
||
if (dingTalkOrganizations.length === 0) {
|
||
if (!dingTalkAppKey) {
|
||
toast({ type: "error", description: "钉钉登录未正确配置" });
|
||
return;
|
||
}
|
||
|
||
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 loginUrl = buildDingTalkLoginUrl();
|
||
logger.debug("[DingTalk] Redirecting OAuth login", { loginUrl });
|
||
window.location.assign(loginUrl);
|
||
return;
|
||
}
|
||
|
||
// 只有一个组织:直接使用该组织登录
|
||
if (dingTalkOrganizations.length === 1) {
|
||
startDingTalkLoginForOrg(dingTalkOrganizations[0] as string);
|
||
return;
|
||
}
|
||
|
||
// 多组织:直接打开弹窗中的组织列表,避免悬浮菜单被页面层级覆盖
|
||
setDingTalkIframeSrc("");
|
||
setShowDingTalkModal(true);
|
||
setShowOrgMenu(false);
|
||
};
|
||
|
||
// 关闭钉钉登录弹窗
|
||
const closeDingTalkModal = () => {
|
||
setShowDingTalkModal(false);
|
||
setDingTalkIframeSrc("");
|
||
};
|
||
|
||
// 决定显示哪个 LOGO
|
||
const displayLogo = mounted
|
||
? (resolvedTheme === "dark" && siteLogoDark ? siteLogoDark : siteLogo)
|
||
: siteLogo;
|
||
const shouldInvertLogo =
|
||
mounted &&
|
||
resolvedTheme === "dark" &&
|
||
!siteLogoDark?.trim() &&
|
||
Boolean(siteLogo?.trim());
|
||
const hasThirdPartyLogin =
|
||
(isDingTalkEnabled && (Boolean(dingTalkAppKey) || dingTalkOrganizations.length > 0)) ||
|
||
(isGoogleEnabled && isGoogleConfigured);
|
||
|
||
if (isCheckingExistingSession) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center text-muted-foreground text-sm">
|
||
正在检查登录状态...
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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={`mb-2 h-12 w-auto object-contain ${shouldInvertLogo ? "brightness-0 invert" : ""}`}
|
||
/>
|
||
) : 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>
|
||
|
||
{hasThirdPartyLogin && (
|
||
<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>
|
||
<div className="space-y-2">
|
||
{isDingTalkEnabled && (dingTalkAppKey || dingTalkOrganizations.length > 0) ? (
|
||
<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>
|
||
) : null}
|
||
|
||
{isGoogleEnabled && isGoogleConfigured ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleGoogleLogin}
|
||
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 width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||
<path fill="#EA4335" d="M12 10.2v3.9h5.5c-.2 1.2-1.4 3.5-5.5 3.5-3.3 0-6-2.7-6-6s2.7-6 6-6c1.9 0 3.2.8 3.9 1.5l2.7-2.6C16.9 2.9 14.6 2 12 2 6.5 2 2 6.5 2 12s4.5 10 10 10c5.8 0 9.6-4.1 9.6-9.8 0-.7-.1-1.2-.2-1.7H12z" />
|
||
</svg>
|
||
Google 登录
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 多组织下的小型选择框(悬浮在按钮附近) */}
|
||
{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/85 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={() => startDingTalkLoginForOrg(org)}
|
||
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] bg-background">
|
||
<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>
|
||
);
|
||
}
|