2026-07-17 19:03:54 +08:00

295 lines
10 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 { useEffect, useRef, useState, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
const DINGTALK_DIRECT_LOGIN_SOURCE = 'work_notice_direct'
function parseDingtalkStateSource(value: string) {
if (!value) return ''
let raw = value
try {
raw = decodeURIComponent(raw)
} catch {
// URLSearchParams 通常已经解码,这里仅兼容手工拼接的 URL。
}
try {
const data = JSON.parse(raw)
return typeof data?.source === 'string' ? data.source : ''
} catch {
return ''
}
}
function parseDingtalkLoginState(value: string) {
let raw = value
try {
raw = decodeURIComponent(raw)
} catch {
// URLSearchParams 通常已解码;兼容手工拼接的 state。
}
try {
const data = JSON.parse(raw)
return {
redirectPath: normalizeInternalRedirectPath(data?.redirect),
nonce: typeof data?.nonce === 'string' ? data.nonce : '',
}
} catch {
return { redirectPath: '/', nonce: '' }
}
}
function buildSessionTicketUrl(handoff: any) {
if (!handoff || typeof handoff.ticket !== 'string' || typeof handoff.targetOrigin !== 'string') {
return null
}
try {
const target = new URL('/api/dingtalk/session-ticket', handoff.targetOrigin)
if (target.origin !== handoff.targetOrigin) return null
target.searchParams.set('ticket', handoff.ticket)
return target.toString()
} catch {
return null
}
}
function DingtalkCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const handledRef = useRef(false)
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [message, setMessage] = useState('正在处理钉钉登录...')
useEffect(() => {
const handleDingtalkLogin = async () => {
try {
if (handledRef.current) {
return
}
handledRef.current = true
const code = searchParams?.get('code') || searchParams?.get('authCode')
const state = searchParams?.get('state') || 'default'
if (!code) {
throw new Error('授权失败:缺少授权码')
}
const { redirectPath, nonce } = parseDingtalkLoginState(state)
setMessage('正在验证授权信息...')
// 调用钉钉登录接口
const response = await fetch('/api/dingtalk/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state }),
})
const data = await response.json()
if (!response.ok || !data.success) {
const loginError = new Error(data.message || '登录失败') as Error & {
handoffTargetOrigin?: string
handoffNonce?: string
}
loginError.handoffTargetOrigin = data?.handoffTargetOrigin
loginError.handoffNonce = data?.handoffNonce
throw loginError
}
// 检查是否需要完善信息
if (data.needCompleteProfile) {
setMessage('首次登录,正在跳转到信息完善页面...')
setStatus('success')
// 跳转到信息完善页面
const userInfoParam = encodeURIComponent(JSON.stringify(data.user))
setTimeout(() => {
if (window.opener) {
// 新窗口模式:通知打开窗口的父页面
window.opener.postMessage(
{
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
nonce,
},
data.handoffTargetOrigin || window.location.origin,
)
window.close()
} else if (window.parent !== window) {
// iframe模式:通知父窗口跳转(兼容旧代码)
window.parent.postMessage(
{
type: 'dingtalk_need_complete_profile',
userInfo: data.user,
nonce,
},
data.handoffTargetOrigin || window.location.origin,
)
} else {
router.push(`/complete-profile?userInfo=${userInfoParam}`)
}
}, 1000)
return
}
setMessage('登录成功,正在跳转...')
setStatus('success')
const sessionHandoff = data.sessionHandoff
const sessionTicketUrl = buildSessionTicketUrl(sessionHandoff)
// 通知父窗口登录成功(如果在 iframe 或新窗口中)
if (window.opener) {
// 新窗口模式:通知打开窗口的父页面
window.opener.postMessage(
{
type: 'dingtalk_login_success',
userInfo: data.user,
redirectUrl: redirectPath,
nonce,
sessionHandoff,
},
sessionHandoff?.targetOrigin || window.location.origin,
)
} else if (window.parent !== window) {
// iframe模式:通知父窗口(兼容旧代码)
window.parent.postMessage(
{
type: 'dingtalk_login_success',
userInfo: data.user,
redirectUrl: redirectPath,
nonce,
sessionHandoff,
},
sessionHandoff?.targetOrigin || window.location.origin,
)
} else {
await new Promise((resolve) => setTimeout(resolve, 200))
const sessionResponse = await fetch('/api/auth/session', {
cache: 'no-store',
credentials: 'include',
})
const sessionData = await sessionResponse.json().catch(() => null)
if (!sessionData?.user) {
throw new Error('登录会话写入失败,请重新使用钉钉登录')
}
setTimeout(() => {
window.location.href = sessionTicketUrl || redirectPath
}, 300)
}
} catch (error: any) {
console.error('钉钉登录错误:', error)
setStatus('error')
setMessage(error.message || '登录失败,请稍后再试')
const state = searchParams?.get('state') || ''
const isDirectWorkNoticeLogin = parseDingtalkStateSource(state) === DINGTALK_DIRECT_LOGIN_SOURCE
// 工作通知直达入口不再退回登录页自动重试,避免重复消费钉钉 code 后形成循环。
if (isDirectWorkNoticeLogin) {
return
}
// 3秒后关闭或跳转
setTimeout(() => {
if (window.opener) {
// 新窗口模式:通知打开窗口的父页面
const targetOrigin = error?.handoffTargetOrigin
if (!targetOrigin || error?.handoffNonce !== parseDingtalkLoginState(state).nonce) {
// 若连受信 handoff 都无法确认,保留错误页给用户看,不能关闭弹窗后让内网页面无提示地卡住。
return
}
window.opener.postMessage(
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
targetOrigin,
)
window.close()
} else if (window.parent !== window) {
// iframe模式:通知父窗口(兼容旧代码)
const targetOrigin = error?.handoffTargetOrigin
if (!targetOrigin || error?.handoffNonce !== parseDingtalkLoginState(state).nonce) {
return
}
window.parent.postMessage(
{ type: 'dingtalk_login_error', message: error.message, nonce: parseDingtalkLoginState(state).nonce },
targetOrigin,
)
} else {
router.push('/login')
}
}, 3000)
}
}
handleDingtalkLogin()
}, [searchParams, router])
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
{status === 'loading' && (
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
)}
{status === 'success' && (
<div className="mb-4">
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{status === 'error' && (
<div className="mb-4">
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<h2 className={`text-xl font-semibold mb-2 ${
status === 'error' ? 'text-red-600' : 'text-gray-800'
}`}>
{status === 'loading' && '钉钉登录'}
{status === 'success' && '登录成功'}
{status === 'error' && '登录失败'}
</h2>
<p className="text-gray-600">{message}</p>
</div>
</div>
</div>
)
}
export default function DingtalkCallbackPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
<div className="text-center">
<div className="mb-4">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent"></div>
</div>
<h2 className="text-xl font-semibold mb-2 text-gray-800">钉钉登录</h2>
<p className="text-gray-600">正在加载...</p>
</div>
</div>
</div>
}>
<DingtalkCallbackContent />
</Suspense>
)
}