b97d0e1a0b
2. 修改角色权限管理的分配用户的数据渲染和接口。 3. 交叉评查任务的创建的组织架构组件的重构。
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
|
||
import { useEffect } from "react";
|
||
import { useSearchParams } from "@remix-run/react";
|
||
import { createUserSession, sessionStorage } from "~/api/login/auth.server";
|
||
import { OAuthClient } from "~/api/login/oauth-client";
|
||
import { getServerOAuthConfigRuntime, getPortOAuthConfig } from "~/config/oauth-secret.server";
|
||
import { loginWithOAuth, type LoginRequest } from "~/api/login/login-client";
|
||
import { isMobileDevice, MOBILE_CHAT_PATH } from "~/utils/mobile-detect.server";
|
||
|
||
/**
|
||
* 端口号到地区的映射关系
|
||
* 根据 ecosystem.config.cjs 配置文件
|
||
*/
|
||
const PORT_TO_AREA_MAP: Record<string, string> = {
|
||
'51703': '梅州',
|
||
'51704': '云浮',
|
||
'51705': '揭阳',
|
||
'51706': '潮州',
|
||
'51707': '省局'
|
||
};
|
||
|
||
/**
|
||
* 根据端口号获取地区
|
||
* @param port - 端口号
|
||
* @returns 地区名称,如果未找到则返回 undefined
|
||
*/
|
||
function getAreaByPort(port: string): string | undefined {
|
||
return PORT_TO_AREA_MAP[port];
|
||
}
|
||
|
||
/**
|
||
* 辅助函数:使用 session flash 重定向到登录页面并传递错误信息
|
||
*/
|
||
async function redirectToLoginWithError(request: Request, errorMessage: string) {
|
||
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||
session.flash("loginError", errorMessage);
|
||
|
||
return redirect("/login", {
|
||
headers: {
|
||
"Set-Cookie": await sessionStorage.commitSession(session)
|
||
}
|
||
});
|
||
}
|
||
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
const url = new URL(request.url);
|
||
// const origin = url.origin; // 获取请求的源 (e.g., "http://10.79.97.17:51703")
|
||
const port = url.port; // 获取端口号
|
||
const area = getAreaByPort(port); // 根据端口号获取地区
|
||
const code = url.searchParams.get("code");
|
||
const state = url.searchParams.get("state");
|
||
const error = url.searchParams.get("error");
|
||
const error_description = url.searchParams.get("error_description");
|
||
|
||
// 🔑 检查是否是管理员账密登录(直接传递 token 和 userInfo)
|
||
const token = url.searchParams.get("token");
|
||
const userInfo = url.searchParams.get("userInfo");
|
||
// const redirectTo = url.searchParams.get("redirectTo") || "/";
|
||
|
||
// 🔑 如果有 token 和 userInfo,说明是管理员账密登录
|
||
// login.tsx action 已经创建了 Cookie Session,这里只需要返回 null
|
||
// 让客户端组件从 URL 读取 token 并保存到 localStorage
|
||
if (token && userInfo) {
|
||
console.log("✅ [Callback] 检测到管理员账密登录,交给客户端处理");
|
||
|
||
// 验证 userInfo 格式(防止解析错误)
|
||
try {
|
||
JSON.parse(decodeURIComponent(userInfo));
|
||
} catch (error) {
|
||
console.error("❌ [Callback] 解析管理员登录用户信息失败:", error);
|
||
return redirectToLoginWithError(request, "登录失败:用户信息格式错误");
|
||
}
|
||
|
||
// ✅ 直接返回 null,让客户端 useEffect 处理
|
||
// 注意:Cookie Session 已经在 login.tsx action 中通过 createUserSession 创建
|
||
return null;
|
||
}
|
||
|
||
// console.log("🔧 OAuth2.0回调参数:", {
|
||
// code: code ? `${code.substring(0, 10)}...` : null,
|
||
// state: state,
|
||
// error: error,
|
||
// error_description: error_description,
|
||
// fullUrl: request.url,
|
||
// port: port,
|
||
// area: area
|
||
// });
|
||
|
||
// 检查是否有错误
|
||
if (error) {
|
||
console.error("❌ OAuth2.0授权失败:", error, error_description);
|
||
return redirectToLoginWithError(request, error_description || error || "授权失败");
|
||
}
|
||
|
||
// 检查是否有授权码
|
||
if (!code) {
|
||
console.error("❌ OAuth2.0回调缺少授权码");
|
||
return redirectToLoginWithError(request, "登录过程中缺少授权码,请重新登录");
|
||
}
|
||
|
||
// 验证状态值
|
||
if (!state || !state.endsWith("_idp")) {
|
||
console.error("❌ OAuth2.0状态值验证失败:", { state, expectedSuffix: "_idp" });
|
||
return redirectToLoginWithError(request, "登录状态验证失败,请重新登录");
|
||
}
|
||
|
||
console.log("✅ OAuth2.0回调参数验证通过");
|
||
|
||
// 🔑 判断是否从钉钉登录:检查 remote-user header
|
||
const remoteUser = request.headers.get("remote-user");
|
||
const isDingTalkLogin = remoteUser !== null;
|
||
|
||
console.log("🔧 [Callback] remote-user 检测:", {
|
||
remoteUser: remoteUser,
|
||
isDingTalkLogin: isDingTalkLogin,
|
||
loginType: isDingTalkLogin ? '钉钉Web登录' : '内网Web登录'
|
||
});
|
||
|
||
// 声明在 try 外部,以便在 catch 中访问
|
||
let tokenResponse = null;
|
||
|
||
// 获取端口特定的OAuth配置(包含钉钉回调地址)
|
||
const portOAuthConfig = getPortOAuthConfig(port);
|
||
const oauthClient = new OAuthClient(portOAuthConfig);
|
||
|
||
// 🔑 根据登录类型设置回调地址
|
||
if (isDingTalkLogin) {
|
||
// 钉钉登录:使用钉钉回调地址
|
||
if (portOAuthConfig.dingtalkRedirectUri) {
|
||
oauthClient.setRedirectUri(portOAuthConfig.dingtalkRedirectUri);
|
||
console.log("🔧 [Callback] 使用钉钉回调地址:", portOAuthConfig.dingtalkRedirectUri);
|
||
} else {
|
||
console.warn("⚠️ [Callback] 钉钉登录但未配置钉钉回调地址,使用默认内网地址");
|
||
}
|
||
} else {
|
||
// 内网登录:使用默认内网回调地址
|
||
console.log("🔧 [Callback] 使用内网回调地址:", portOAuthConfig.redirectUri);
|
||
}
|
||
|
||
try {
|
||
console.log("🔧 开始处理OAuth2.0回调");
|
||
|
||
// 开始获取访问令牌
|
||
tokenResponse = await oauthClient.getAccessToken(code);
|
||
if (!tokenResponse) {
|
||
console.error("获取访问令牌失败");
|
||
// 注意:此时还没有 access_token,无法调用 IDaaS 登出
|
||
// 只能重定向到登录页,让用户重新开始登录流程
|
||
return redirectToLoginWithError(request, "获取访问令牌失败,请重试");
|
||
}
|
||
console.log("✅ [Callback] 访问令牌获取成功");
|
||
|
||
|
||
const userInfo = await oauthClient.getUserInfo(tokenResponse.access_token);
|
||
if(!userInfo || !userInfo.success){
|
||
console.error('获取用户信息失败:',userInfo);
|
||
|
||
// 🔑 关键:此时 IDaaS 那边已经登录成功,但我们获取用户信息失败
|
||
// 需要调用 IDaaS 登出,清除 IDaaS 的登录状态,避免用户下次登录时出现问题
|
||
try {
|
||
const logoutUrl = `${url.protocol}//${url.host}/login`;
|
||
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
|
||
if (logoutSuccess) {
|
||
console.log("✅ [Callback] 已清除 IDaaS 登录状态");
|
||
} else {
|
||
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败");
|
||
}
|
||
} catch (logoutError) {
|
||
console.error("❌ [Callback] 调用 IDaaS 登出时出错:", logoutError);
|
||
}
|
||
|
||
return redirectToLoginWithError(request, "获取用户信息失败,请重试");
|
||
}
|
||
console.log("✅ [Callback] 用户信息获取成功");
|
||
|
||
// 🔑 检测移动端设备,决定重定向目标
|
||
const isMobile = isMobileDevice(request);
|
||
console.log(`📱 [Callback] 设备类型检测: ${isMobile ? '移动端' : '桌面端'}`);
|
||
|
||
// 移动端用户直接跳转到对话页面,桌面端用户跳转到首页选择模块
|
||
const redirectTo = isMobile ? MOBILE_CHAT_PATH : "/";
|
||
|
||
// 调用后端登录接口,传递 OAuth 用户信息,获取 JWT token
|
||
const loginRequest: LoginRequest = {
|
||
userInfo: userInfo.data,
|
||
expiresIn: tokenResponse.expires_in,
|
||
area: area
|
||
};
|
||
|
||
const loginResponse = await loginWithOAuth(loginRequest);
|
||
|
||
if (!loginResponse.success || !loginResponse.data) {
|
||
console.error("❌ [Callback] 后端登录失败:", loginResponse.error);
|
||
|
||
// 🔑 登录失败,需要清除 IDaaS 登录状态
|
||
try {
|
||
const logoutUrl = `${url.protocol}//${url.host}/login`;
|
||
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
|
||
if (logoutSuccess) {
|
||
console.log("✅ [Callback] 已清除 IDaaS 登录状态(后端登录失败)");
|
||
} else {
|
||
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(后端登录失败)");
|
||
}
|
||
} catch (logoutError) {
|
||
console.error("❌ [Callback] 调用 IDaaS 登出时出错(后端登录失败):", logoutError);
|
||
}
|
||
|
||
return redirectToLoginWithError(request, loginResponse.error || "登录失败,请重新登录");
|
||
}
|
||
|
||
console.log("✅ [Callback] 后端登录成功,JWT token 已获取");
|
||
const frontendJWT = loginResponse.data.access_token;
|
||
const savedUserInfo = loginResponse.data.user_info;
|
||
const backExpiresIn = loginResponse.data.expires_in || (60 * 60 * 8)
|
||
|
||
// 🔑 提取后端返回的签发时间并转换为时间戳
|
||
let tokenIssuedAt = Date.now(); // 默认使用当前时间
|
||
if (loginResponse.data.issued_time) {
|
||
try {
|
||
// 后端返回格式:"2025-11-18 17:41:06"
|
||
// 转换为时间戳(毫秒)
|
||
tokenIssuedAt = new Date(loginResponse.data.issued_time.replace(' ', 'T')).getTime();
|
||
console.log("📅 [Callback] 使用后端返回的签发时间:", loginResponse.data.issued_time, "→", tokenIssuedAt);
|
||
} catch (error) {
|
||
console.warn("⚠️ [Callback] 无法解析 issued_time,使用当前时间:", error);
|
||
}
|
||
}
|
||
|
||
// 更新userInfo以包含数据库ID、JWT(user_role 从后端返回)
|
||
const enhancedUserInfo = {
|
||
...userInfo.data, // 保留OAuth返回的原字段(包含 nickname 等)
|
||
username: savedUserInfo.username,
|
||
nick_name: savedUserInfo.nick_name,
|
||
phone_number: savedUserInfo.phone_number,
|
||
email: savedUserInfo.email,
|
||
ou_id: savedUserInfo.ou_id,
|
||
ou_name: savedUserInfo.ou_name,
|
||
is_leader: savedUserInfo.is_leader,
|
||
user_id: savedUserInfo.user_id,
|
||
user_role: savedUserInfo.user_role, // 使用后端返回的角色
|
||
area: savedUserInfo.area, // 🔑 用户所属地区
|
||
frontend_jwt: frontendJWT,
|
||
// 🔑 包含后端返回的组织信息字段(可能为null)
|
||
tenant_name: savedUserInfo.tenant_name,
|
||
dep_name: savedUserInfo.dep_name,
|
||
dep_short_name: savedUserInfo.dep_short_name,
|
||
};
|
||
|
||
// 🔑 重要:将 token 和用户信息作为 URL 参数传递给客户端
|
||
// 客户端 useEffect 会将其保存到 localStorage
|
||
const callbackUrl = new URL('/callback', url.origin);
|
||
callbackUrl.searchParams.set('token', frontendJWT);
|
||
callbackUrl.searchParams.set('userInfo', encodeURIComponent(JSON.stringify({
|
||
user_id: savedUserInfo.user_id,
|
||
username: savedUserInfo.username,
|
||
nick_name: savedUserInfo.nick_name,
|
||
email: savedUserInfo.email,
|
||
phone_number: savedUserInfo.phone_number,
|
||
ou_id: savedUserInfo.ou_id,
|
||
ou_name: savedUserInfo.ou_name,
|
||
is_leader: savedUserInfo.is_leader,
|
||
user_role: savedUserInfo.user_role,
|
||
area: savedUserInfo.area, // 🔑 用户所属地区
|
||
sub: userInfo.data.sub,
|
||
// 🔑 包含后端返回的组织信息字段(可能为null)
|
||
tenant_name: savedUserInfo.tenant_name,
|
||
dep_name: savedUserInfo.dep_name,
|
||
dep_short_name: savedUserInfo.dep_short_name,
|
||
})));
|
||
callbackUrl.searchParams.set('redirectTo', redirectTo);
|
||
|
||
// 使用统一的session创建函数
|
||
return createUserSession({
|
||
isAuthenticated: true,
|
||
userRole: savedUserInfo.user_role, // 使用后端返回的角色
|
||
redirectTo: callbackUrl.toString(), // 先跳转到 callback 页面保存 token
|
||
accessToken: tokenResponse.access_token,
|
||
refreshToken: tokenResponse.refresh_token,
|
||
tokenExpiresIn: backExpiresIn,
|
||
tokenIssuedAt: tokenIssuedAt, // 🔑 传递后端返回的签发时间
|
||
userInfo: enhancedUserInfo,
|
||
frontendJWT
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("OAuth2.0回调处理失败:", error);
|
||
|
||
// 🔑 如果已经获取到了 access_token,需要清除 IDaaS 登录状态
|
||
if (tokenResponse?.access_token) {
|
||
try {
|
||
const logoutUrl = `${url.protocol}//${url.host}/login`;
|
||
const logoutSuccess = await oauthClient.logout(tokenResponse.access_token, logoutUrl);
|
||
if (logoutSuccess) {
|
||
console.log("✅ [Callback] 已清除 IDaaS 登录状态(回调处理异常)");
|
||
} else {
|
||
console.warn("⚠️ [Callback] 清除 IDaaS 登录状态失败(回调处理异常)");
|
||
}
|
||
} catch (logoutError) {
|
||
console.error("❌ [Callback] 调用 IDaaS 登出时出错(回调处理异常):", logoutError);
|
||
}
|
||
}
|
||
|
||
return redirectToLoginWithError(request, "登录回调处理失败,请重新登录");
|
||
}
|
||
}
|
||
|
||
export default function Callback() {
|
||
const [searchParams] = useSearchParams();
|
||
|
||
useEffect(() => {
|
||
// 从 URL 参数中获取 token(如果有)
|
||
const token = searchParams.get("token");
|
||
const userInfo = searchParams.get("userInfo");
|
||
// 从 URL 参数中获取重定向目标(服务端已根据设备类型设置)
|
||
const redirectTo = searchParams.get("redirectTo") || "/";
|
||
|
||
if (token && typeof window !== 'undefined') {
|
||
console.log('🔑 [Callback] 开始保存 token 到 localStorage');
|
||
|
||
// 存储 token 到 localStorage
|
||
localStorage.setItem('access_token', token);
|
||
console.log('✅ [Callback] Token 已存储到 localStorage');
|
||
|
||
// 存储用户信息
|
||
if (userInfo) {
|
||
try {
|
||
const parsedUserInfo = JSON.parse(decodeURIComponent(userInfo));
|
||
localStorage.setItem('user_info', JSON.stringify(parsedUserInfo));
|
||
console.log('✅ [Callback] 用户信息已存储到 localStorage:', parsedUserInfo);
|
||
} catch (error) {
|
||
console.error('❌ [Callback] 解析用户信息失败:', error);
|
||
}
|
||
}
|
||
|
||
// ⏱️ 短暂延迟后跳转,确保 localStorage 写入完成
|
||
// 使用 requestAnimationFrame 确保 DOM 更新后立即跳转
|
||
requestAnimationFrame(() => {
|
||
setTimeout(() => {
|
||
console.log(`🚀 [Callback] 跳转到目标页面: ${redirectTo}`);
|
||
window.location.href = redirectTo;
|
||
}, 100); // 减少延迟从 500ms 到 100ms
|
||
});
|
||
}
|
||
}, [searchParams]);
|
||
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||
<p className="mt-4 text-gray-600">正在处理登录...</p>
|
||
<p className="mt-2 text-sm text-gray-500">即将跳转...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |