359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useActionData, useLoaderData, Form } from "@remix-run/react";
|
||
import { type MetaFunction, type LoaderFunctionArgs, type ActionFunctionArgs, redirect } from "@remix-run/node";
|
||
import { OAuthClient } from "~/api/login/oauth-client";
|
||
import { CLIENT_OAUTH_CONFIG } from "~/config/api-config";
|
||
import { getUserSession, getSession, simpleRootLogin } from "~/api/login/auth.server";
|
||
import styles from "~/styles/pages/login.css?url";
|
||
import { toastService } from "~/components/ui";
|
||
|
||
export const links = () => [
|
||
{ rel: "stylesheet", href: styles }
|
||
];
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "中国烟草AI合同及卷宗审核系统 - 登录" },
|
||
{ name: "description", content: "中国烟草AI合同及卷宗审核系统登录页面" },
|
||
];
|
||
};
|
||
|
||
// 加载器,获取当前会话状态
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
const { isAuthenticated } = await getUserSession(request);
|
||
|
||
// 如果已登录,重定向到首页
|
||
if (isAuthenticated) {
|
||
return redirect("/");
|
||
}
|
||
|
||
// 获取重定向URL并保存到session
|
||
const url = new URL(request.url);
|
||
const redirectTo = url.searchParams.get("redirect") || "/";
|
||
|
||
const session = await getSession(request);
|
||
|
||
// 读取 flash 消息(来自 callback 的错误)
|
||
const loginError = session.get("loginError");
|
||
|
||
session.set("redirectTo", redirectTo);
|
||
|
||
// 提交 session 以清除 flash 消息
|
||
if (loginError) {
|
||
const { sessionStorage } = await import("~/api/login/auth.server");
|
||
return Response.json({
|
||
isAuthenticated: false,
|
||
redirectTo,
|
||
flashError: loginError
|
||
}, {
|
||
headers: {
|
||
"Set-Cookie": await sessionStorage.commitSession(session)
|
||
}
|
||
});
|
||
}
|
||
|
||
return Response.json({
|
||
isAuthenticated: false,
|
||
redirectTo,
|
||
flashError: null
|
||
});
|
||
}
|
||
|
||
// 处理表单提交的action函数
|
||
export async function action({ request }: ActionFunctionArgs) {
|
||
const formData = await request.formData();
|
||
const intent = formData.get("intent");
|
||
const username = formData.get("username")?.toString().trim();
|
||
const password = formData.get("password")?.toString().trim();
|
||
|
||
if (intent === "password_login") {
|
||
// 获取重定向目标
|
||
const session = await getSession(request);
|
||
const redirectTo = session.get("redirectTo") || "/";
|
||
|
||
// 调用 simpleRootLogin 方法进行登录
|
||
const response = await simpleRootLogin(username || "", password || "", redirectTo);
|
||
|
||
// 检查响应状态
|
||
if (response.status === 302) {
|
||
// 登录成功,直接返回重定向响应
|
||
return response;
|
||
} else {
|
||
// 登录失败,返回错误信息(不再使用URL参数)
|
||
const errorData = await response.json();
|
||
return Response.json({
|
||
success: false,
|
||
error: errorData.error || "登录失败",
|
||
retryCount: errorData.retryCount || 0,
|
||
isLocked: errorData.isLocked || false,
|
||
remainingAttempts: errorData.remainingAttempts || 5
|
||
}, {
|
||
status: response.status
|
||
});
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export default function Login() {
|
||
const actionData = useActionData<typeof action>();
|
||
const loaderData = useLoaderData<typeof loader>();
|
||
const [isFlipped, setIsFlipped] = useState(false);
|
||
const [username, setUsername] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
|
||
// 从 actionData 或 loaderData 中获取错误信息
|
||
// actionData 的错误优先(来自密码登录)
|
||
// loaderData.flashError 次之(来自 OAuth 回调)
|
||
const error = actionData?.error || loaderData?.flashError;
|
||
const isLocked = actionData?.isLocked || false;
|
||
const retryCount = actionData?.retryCount || 0;
|
||
const remainingAttempts = actionData?.remainingAttempts || 5;
|
||
|
||
// 处理OAuth2.0登录
|
||
const handleOAuthLogin = () => {
|
||
try {
|
||
// 创建OAuth客户端(使用客户端安全配置,不包含 clientSecret)
|
||
const oauthClient = new OAuthClient(CLIENT_OAUTH_CONFIG);
|
||
|
||
// 生成状态值
|
||
const state = oauthClient.generateState();
|
||
|
||
// 将状态值保存到localStorage(用于后续验证)
|
||
localStorage.setItem("oauth_state", state);
|
||
|
||
// 获取授权URL
|
||
const authorizeUrl = oauthClient.getAuthorizeUrl(state);
|
||
console.log("授权URL:", authorizeUrl);
|
||
// 重定向到IDaaS登录页面
|
||
window.location.href = authorizeUrl;
|
||
} catch (error) {
|
||
console.error("启动OAuth2.0登录失败:", error);
|
||
alert("登录系统初始化失败,请联系系统管理员");
|
||
}
|
||
};
|
||
|
||
// 处理管理员登录
|
||
const handleAdminLogin = () => {
|
||
setIsFlipped(true);
|
||
};
|
||
|
||
// 处理返回OAuth登录
|
||
const handleBackToOAuth = () => {
|
||
setIsFlipped(false);
|
||
setUsername("");
|
||
setPassword("");
|
||
};
|
||
|
||
// 处理账号密码登录表单提交
|
||
const handlePasswordLoginSubmit = (e: React.FormEvent) => {
|
||
// 检查账户是否被锁定
|
||
if (isLocked) {
|
||
e.preventDefault();
|
||
toastService.error("账户已被锁定,请联系管理员");
|
||
return;
|
||
}
|
||
|
||
// 客户端验证
|
||
if (!username.trim()) {
|
||
e.preventDefault();
|
||
toastService.error("请输入用户名");
|
||
return;
|
||
}
|
||
|
||
if (!password.trim()) {
|
||
e.preventDefault();
|
||
toastService.error("请输入密码");
|
||
return;
|
||
}
|
||
|
||
// 验证通过,让表单正常提交
|
||
};
|
||
|
||
useEffect(() => {
|
||
// 检查OAuth配置是否完整(客户端不需要检查 clientSecret)
|
||
if (!CLIENT_OAUTH_CONFIG.serverUrl || !CLIENT_OAUTH_CONFIG.clientId) {
|
||
console.error("OAuth2.0配置不完整:", CLIENT_OAUTH_CONFIG);
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div className="login-page">
|
||
<div className={`login-container ${isFlipped ? 'flipped' : ''}`}>
|
||
<div className="login-card">
|
||
{/* 正面 - OAuth登录 */}
|
||
<div className="login-card-front">
|
||
<div className="login-header">
|
||
<h1 className="login-title">中国烟草AI合同及卷宗审核系统</h1>
|
||
</div>
|
||
|
||
<div className="login-form-container">
|
||
<h2 className="login-subtitle">统一身份认证登录</h2>
|
||
|
||
{error && (
|
||
<div className={`error-message-container ${isLocked ? 'locked' : ''}`}>
|
||
<div className="error-icon">
|
||
{isLocked ? (
|
||
<i className="ri-lock-line"></i>
|
||
) : (
|
||
<i className="ri-error-warning-line"></i>
|
||
)}
|
||
</div>
|
||
<div className="error-text">
|
||
{error}
|
||
{!isLocked && retryCount > 0 && (
|
||
<div className="retry-info" style={{ fontSize: '0.9em', marginTop: '4px', opacity: 0.9 }}>
|
||
剩余尝试次数:{remainingAttempts} 次
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="oauth-login-section">
|
||
<div className="login-description">
|
||
<p>请点击下方按钮进行统一身份认证登录</p>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleOAuthLogin}
|
||
className="oauth-login-button"
|
||
type="button"
|
||
>
|
||
<i className="ri-shield-user-line"></i>
|
||
统一身份认证登录
|
||
</button>
|
||
|
||
<div className="login-tips">
|
||
<p>
|
||
<i className="ri-information-line"></i>
|
||
系统将跳转到统一身份认证平台进行登录
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 管理员登录链接 */}
|
||
<div className="admin-login-link">
|
||
<button
|
||
onClick={handleAdminLogin}
|
||
className="admin-login-text"
|
||
type="button"
|
||
>
|
||
管理员登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="login-footer">
|
||
<p>© 2025 中国烟草 版权所有</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 背面 - 管理员登录 */}
|
||
<div className="login-card-back">
|
||
<div className="login-header">
|
||
<h1 className="login-title">中国烟草AI合同及卷宗审核系统</h1>
|
||
</div>
|
||
|
||
<div className="login-form-container">
|
||
<h2 className="login-subtitle">管理员登录</h2>
|
||
|
||
{error && (
|
||
<div className={`error-message-container ${isLocked ? 'locked' : ''}`}>
|
||
<div className="error-icon">
|
||
{isLocked ? (
|
||
<i className="ri-lock-line"></i>
|
||
) : (
|
||
<i className="ri-error-warning-line"></i>
|
||
)}
|
||
</div>
|
||
<div className="error-text">
|
||
{error}
|
||
{!isLocked && retryCount > 0 && (
|
||
<div className="retry-info" style={{ fontSize: '0.9em', marginTop: '4px', opacity: 0.9 }}>
|
||
剩余尝试次数:{remainingAttempts} 次
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<Form method="post" className="admin-login-form" onSubmit={handlePasswordLoginSubmit}>
|
||
<input type="hidden" name="intent" value="password_login" />
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="username" className="form-label">用户名</label>
|
||
<input
|
||
type="text"
|
||
id="username"
|
||
name="username"
|
||
value={username}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
className="form-input"
|
||
placeholder="请输入用户名"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-group">
|
||
<label htmlFor="password" className="form-label">密码</label>
|
||
<input
|
||
type="password"
|
||
id="password"
|
||
name="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
className="form-input"
|
||
placeholder="请输入密码"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="admin-login-button"
|
||
disabled={isLocked}
|
||
style={isLocked ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||
>
|
||
<i className={isLocked ? "ri-lock-line" : "ri-login-box-line"}></i>
|
||
{isLocked ? "账户已锁定" : "登录"}
|
||
</button>
|
||
|
||
{isLocked && (
|
||
<div style={{
|
||
marginTop: '12px',
|
||
padding: '8px',
|
||
background: '#fff3cd',
|
||
border: '1px solid #ffc107',
|
||
borderRadius: '4px',
|
||
fontSize: '0.9em',
|
||
textAlign: 'center',
|
||
color: '#856404'
|
||
}}>
|
||
<i className="ri-information-line"></i> 账户已被锁定,请联系管理员解锁
|
||
</div>
|
||
)}
|
||
</Form>
|
||
|
||
<div className="back-to-oauth">
|
||
<button
|
||
onClick={handleBackToOAuth}
|
||
className="back-button"
|
||
type="button"
|
||
>
|
||
<i className="ri-arrow-left-line"></i>
|
||
返回统一身份认证登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="login-footer">
|
||
<p>© 2025 中国烟草 版权所有</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |