gongxiangp2/app/login/page.tsx
2026-07-17 18:55:37 +08:00

388 lines
15 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 { useState, useEffect } from 'react'
import { signIn } from 'next-auth/react'
import Image from 'next/image'
import Link from 'next/link'
import { ensureHttpsUrl } from '@/lib/url-rewrite'
import { parseApiError } from '@/lib/error-handler'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
const DEFAULT_LOGIN_LOGO_URL = '/api/favicon'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// 注意:钉钉登录现在使用新窗口模式,不再需要iframe弹窗
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
const [siteName, setSiteName] = useState('由壹点3D素材')
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
const publicAppOrigin = getPublicAppOrigin()
const dingtalkCallbackOrigin =
process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin
const getLoginRedirectPath = () => {
if (typeof window === 'undefined') return '/'
return normalizeInternalRedirectPath(new URLSearchParams(window.location.search).get('redirect'))
}
useEffect(() => {
if (typeof window === 'undefined') return
const urlParams = new URLSearchParams(window.location.search)
if (
urlParams.get('dingtalk_redirect') === 'true' ||
urlParams.get('dingtalk_success') === 'true'
) {
return
}
const isDingtalkClient = /DingTalk/i.test(window.navigator.userAgent || '')
if (!isDingtalkClient) return
const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect'), '/')
window.location.replace(`/dingtalk-entry?redirect=${encodeURIComponent(redirectPath)}`)
}, [])
// 加载系统设置
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
// 确保 logo URL 使用 HTTPS,避免混合内容问题
const logoUrl = settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL
setLoginLogoUrl(ensureHttpsUrl(logoUrl) || logoUrl)
setSiteName(settings.siteName || '由壹点3D素材')
}
} catch (error) {
console.error('加载系统设置失败:', error)
// 即使加载失败,也确保默认 URL 使用 HTTPS
const defaultUrl = DEFAULT_LOGIN_LOGO_URL
setLoginLogoUrl(ensureHttpsUrl(defaultUrl) || defaultUrl)
}
}
loadSettings()
}, [])
// 页面加载时识别访问环境
useEffect(() => {
// 不输出hostname信息,避免在浏览器控制台暴露内网信息
// 注意:内网检测已改为后端实现(/api/network-status)
// 登录成功后会在首页自动调用后端API判断是否需要显示切换提示
}, [])
// 处理从内网跳转来的钉钉登录请求
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkRedirect = urlParams.get('dingtalk_redirect')
if (dingtalkRedirect === 'true') {
const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect'))
console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')
// 清理URL参数
window.history.replaceState({}, '', `/login?redirect=${encodeURIComponent(redirectPath)}`)
// 等待页面加载完成后触发钉钉登录
const timer = setTimeout(() => {
handleDingtalkLogin({ redirectPath, useCurrentWindow: true })
}, 500)
return () => clearTimeout(timer)
}
// 自动钉钉入口只应在页面首次打开时判断一次,后续渲染不重复拉起授权。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
console.log('🔐 开始登录流程...', { email, hasPassword: !!password })
// 验证输入
if (!email || !password) {
setError('请输入邮箱和密码')
return
}
setError('')
setLoading(true)
try {
console.log('📤 调用 signIn...')
const result = await signIn('credentials', {
email: email.trim(),
password,
redirect: false,
})
console.log('📥 signIn 返回结果:', result)
// 处理结果
if (result?.error) {
console.error('❌ 登录失败:', result.error)
setError(result.error === 'CredentialsSignin' ? '邮箱或密码错误' : result.error)
} else if (result?.ok !== false) {
// result.ok 为 true 或 undefined 都视为成功
console.log('✅ 登录成功,等待 cookie 设置完成...')
// 等待一小段时间,确保 NextAuth 的 cookie 已经设置完成
// 这样可以避免 middleware 检测不到 token 而重定向回登录页
// 增加延迟时间,确保 cookie 已经写入浏览器
await new Promise(resolve => setTimeout(resolve, 300))
console.log('🔄 开始跳转到首页...')
// 使用 window.location.href 强制跳转,确保跳转生效
// 使用完整的 URL 可以确保 cookie 正确传递
window.location.href = window.location.origin + getLoginRedirectPath()
// 注意:跳转后不需要 setLoading(false),因为页面会刷新
return
} else {
console.warn('⚠️ signIn 返回了意外的结果:', result)
setError('登录失败,请稍后再试')
}
} catch (error: any) {
console.error('❌ 登录异常:', error)
const { message } = parseApiError(error)
setError(message)
} finally {
setLoading(false)
}
}
const handleDingtalkLogin = async (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback`
const redirectPath = normalizeInternalRedirectPath(options?.redirectPath ?? getLoginRedirectPath())
const nonce = typeof window.crypto?.randomUUID === 'function'
? window.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
let handoffId = ''
// 内网入口先在本机写入短时 HttpOnly 绑定 Cookie。OAuth 回到公网后只能携带无语义 ID,
// 最终仍必须由同一浏览器带着该 Cookie 消费一次性票据,避免登录注入和开放重定向。
try {
const handoffResponse = await fetch('/api/dingtalk/session-transfer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ redirectPath }),
})
const handoffData = handoffResponse.ok
? await handoffResponse.json().catch(() => null)
: null
if (handoffData?.enabled && typeof handoffData.handoffId === 'string') {
handoffId = handoffData.handoffId
}
} catch (error) {
// 交接不可用时仍允许原有公网钉钉登录,不能把用户卡在登录页。
console.warn('创建钉钉内网登录交接请求失败,将使用公网登录:', error)
}
const stateData = JSON.stringify({ redirect: redirectPath, nonce, handoffId })
const params = new URLSearchParams({
redirect_uri: redirectUrl,
client_id: DINGTALK_CLIENT_ID,
scope: 'openid',
response_type: 'code',
state: stateData,
})
if (!options?.useCurrentWindow) {
params.set('prompt', 'consent')
}
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?${params.toString()}`
if (options?.useCurrentWindow) {
window.location.href = dingtalkAuthUrl
return
}
// 使用新窗口打开钉钉登录,避免iframe的cookie限制问题
// 新窗口可以正常访问cookie,解决"授权失败"的问题
const width = 500
const height = 600
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
const popup = window.open(
dingtalkAuthUrl,
'钉钉登录',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
)
if (!popup) {
setError('无法打开登录窗口,请检查浏览器弹窗设置')
return
}
// 监听新窗口的消息(从回调页面发送)
const callbackMessageOrigin = new URL(dingtalkCallbackOrigin).origin
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== callbackMessageOrigin) {
return
}
if (!event.data || event.data.nonce !== nonce) {
return
}
if (event.data.type === 'dingtalk_login_success') {
popup?.close()
window.removeEventListener('message', handleMessage)
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
const handoff = event.data.sessionHandoff
const canReturnToCurrentOrigin =
typeof handoff?.ticket === 'string' &&
handoff.nonce === nonce &&
handoff.targetOrigin === window.location.origin
if (canReturnToCurrentOrigin) {
const ticketUrl = new URL('/api/dingtalk/session-ticket', window.location.origin)
ticketUrl.searchParams.set('ticket', handoff.ticket)
window.location.href = ticketUrl.toString()
return
}
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
} else if (event.data.type === 'dingtalk_need_complete_profile') {
popup?.close()
window.removeEventListener('message', handleMessage)
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
window.location.href = new URL(
`/complete-profile?userInfo=${userInfoParam}`,
dingtalkCallbackOrigin,
).toString()
} else if (event.data && event.data.type === 'dingtalk_login_error') {
popup?.close()
window.removeEventListener('message', handleMessage)
setError(event.data.message || '钉钉登录失败')
}
}
window.addEventListener('message', handleMessage)
// 检查新窗口是否被关闭(用户手动关闭)
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
window.removeEventListener('message', handleMessage)
}
}, 1000)
}
// 注意:钉钉登录现在使用新窗口模式,消息监听在 handleDingtalkLogin 中处理
// 这样可以避免iframe的cookie限制问题
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
<div className="max-w-md w-full">
{/* Logo */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Image
src={loginLogoUrl}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-apple shadow-apple"
/>
</div>
<h1 className="text-3xl font-bold text-gray-900">{siteName}</h1>
<p className="text-gray-600 mt-2">欢迎回来,请登录您的账号</p>
</div>
{/* Login Form */}
<div className="card-apple p-8">
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-apple text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
邮箱
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input-apple"
placeholder="your@email.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-apple"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
className="w-full btn-primary"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="mt-6">
{/* 分隔线 */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">或</span>
</div>
</div>
{/* 钉钉登录按钮 */}
<button
id="dingtalk-login-btn"
type="button"
onClick={() => handleDingtalkLogin()}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-xl shadow-sm bg-white hover:bg-gray-50 transition-colors"
>
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
<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"></path>
</svg>
<span className="text-lg font-medium text-gray-700">钉钉登录</span>
</button>
</div>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
还没有账号?{' '}
<Link
href="/register"
className="text-gray-900 hover:text-gray-700 font-medium underline"
>
立即注册
</Link>
</p>
</div>
</div>
</div>
</div>
)
}