Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { signIn } from 'next-auth/react'
|
||
import Link from 'next/link'
|
||
import BrandLogo from '@/components/BrandLogo'
|
||
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
|
||
import { parseApiError } from '@/lib/error-handler'
|
||
import { getPublicAppOrigin } from '@/lib/public-app-url'
|
||
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
|
||
|
||
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()
|
||
const logoUrl = settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL
|
||
setLoginLogoUrl(logoUrl)
|
||
setSiteName(settings.siteName || '由壹点3D素材')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载系统设置失败:', error)
|
||
setLoginLogoUrl(DEFAULT_LOGIN_LOGO_URL)
|
||
}
|
||
}
|
||
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">
|
||
<BrandLogo
|
||
src={loginLogoUrl}
|
||
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
||
alt=""
|
||
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>
|
||
)
|
||
}
|