247 lines
8.0 KiB
TypeScript
247 lines
8.0 KiB
TypeScript
'use client'
|
||
|
||
import { Suspense, useState, useMemo } from 'react'
|
||
import { useSearchParams } from 'next/navigation'
|
||
|
||
interface PasswordStrength {
|
||
minLength: boolean
|
||
hasUpper: boolean
|
||
hasLower: boolean
|
||
hasDigit: boolean
|
||
hasSpecial: boolean
|
||
score: number
|
||
}
|
||
|
||
function checkPassword(pw: string): PasswordStrength {
|
||
const minLength = pw.length >= 8
|
||
const hasUpper = /[A-Z]/.test(pw)
|
||
const hasLower = /[a-z]/.test(pw)
|
||
const hasDigit = /[0-9]/.test(pw)
|
||
const hasSpecial = /[^A-Za-z0-9]/.test(pw)
|
||
const score = [hasUpper, hasLower, hasDigit, hasSpecial].filter(Boolean).length
|
||
return { minLength, hasUpper, hasLower, hasDigit, hasSpecial, score }
|
||
}
|
||
|
||
const checks: { key: keyof PasswordStrength; label: string }[] = [
|
||
{ key: 'minLength', label: '至少 8 位字符' },
|
||
{ key: 'hasUpper', label: '包含大写字母' },
|
||
{ key: 'hasLower', label: '包含小写字母' },
|
||
{ key: 'hasDigit', label: '包含数字' },
|
||
{ key: 'hasSpecial', label: '包含特殊字符' },
|
||
]
|
||
|
||
function SetupPasswordForm() {
|
||
const searchParams = useSearchParams()
|
||
const token = searchParams.get('token') || ''
|
||
const [password, setPassword] = useState('')
|
||
const [confirm, setConfirm] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState(false)
|
||
const [loading, setLoading] = useState(false)
|
||
|
||
const strength = useMemo(() => checkPassword(password), [password])
|
||
const isStrong = strength.minLength && strength.score >= 3
|
||
const passwordMismatch = confirm.length > 0 && password !== confirm
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setError('')
|
||
|
||
if (!isStrong) {
|
||
setError('请先满足密码复杂度要求')
|
||
return
|
||
}
|
||
if (password !== confirm) {
|
||
setError('两次输入的密码不一致')
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
try {
|
||
const res = await fetch('/api/auth/setup-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ token, password }),
|
||
})
|
||
const data = await res.json()
|
||
if (!res.ok) {
|
||
setError(data.error || '设置失败')
|
||
} else {
|
||
setSuccess(true)
|
||
}
|
||
} catch {
|
||
setError('网络错误,请重试')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (!token) {
|
||
return (
|
||
<div style={styles.container}>
|
||
<div style={styles.card}>
|
||
<h1 style={styles.title}>链接无效</h1>
|
||
<p style={styles.text}>缺少设置密码所需的 token,请检查链接是否完整。</p>
|
||
<a href="/login" style={styles.link}>返回登录</a>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (success) {
|
||
return (
|
||
<div style={styles.container}>
|
||
<div style={styles.card}>
|
||
<h1 style={{ ...styles.title, color: '#16a34a' }}>密码已设置</h1>
|
||
<p style={styles.text}>您的密码已成功设置,现在可以使用新密码登录。</p>
|
||
<a href="/login" style={styles.link}>前往登录</a>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={styles.container}>
|
||
<form onSubmit={handleSubmit} style={styles.card}>
|
||
<h1 style={styles.title}>设置登录密码</h1>
|
||
<p style={styles.text}>请为您的 OA 账号设置登录密码。</p>
|
||
|
||
{error && <div style={styles.error}>{error}</div>}
|
||
|
||
<label style={styles.label}>
|
||
新密码
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
style={styles.input}
|
||
placeholder="至少 8 位,大写/小写/数字/特殊字符 4 选 3"
|
||
autoFocus
|
||
/>
|
||
|
||
{password.length > 0 && (
|
||
<div style={styles.complexityPanel}>
|
||
<p style={styles.complexityHint}>需满足至少 8 位 + 以下 3 项:</p>
|
||
{checks.map(c => {
|
||
const ok = c.key === 'minLength' ? strength.minLength : strength[c.key]
|
||
return (
|
||
<div key={c.key} style={styles.checkItem}>
|
||
<span style={{
|
||
display: 'inline-flex', width: 18, height: 18, borderRadius: '50%',
|
||
background: ok ? '#dcfce7' : '#f1f5f9',
|
||
color: ok ? '#16a34a' : '#94a3b8',
|
||
alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 11, fontWeight: 700, flexShrink: 0,
|
||
}}>
|
||
{ok ? '✓' : '—'}
|
||
</span>
|
||
<span style={{ color: ok ? '#16a34a' : '#64748b' }}>{c.label}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
<p style={{ fontSize: 12, marginTop: 6, color: isStrong ? '#16a34a' : '#d97706' }}>
|
||
{isStrong ? '密码强度符合要求' : `已满足 ${strength.score} 项(需至少 3 项)${strength.minLength ? '' : ',长度不足 8 位'}`}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</label>
|
||
|
||
<label style={styles.label}>
|
||
确认密码
|
||
<input
|
||
type="password"
|
||
value={confirm}
|
||
onChange={(e) => setConfirm(e.target.value)}
|
||
style={{
|
||
...styles.input,
|
||
borderColor: passwordMismatch ? '#dc2626' : '#cbd5e1',
|
||
}}
|
||
placeholder="再次输入新密码"
|
||
/>
|
||
{passwordMismatch && (
|
||
<p style={{ color: '#dc2626', fontSize: 12, marginTop: 6 }}>两次输入的密码不一致</p>
|
||
)}
|
||
</label>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading || !isStrong || passwordMismatch}
|
||
style={{
|
||
...styles.btn,
|
||
background: loading || !isStrong || passwordMismatch ? '#cbd5e1' : '#2563eb',
|
||
cursor: loading || !isStrong || passwordMismatch ? 'not-allowed' : 'pointer',
|
||
}}
|
||
>
|
||
{loading ? '设置中...' : passwordMismatch ? '请确认密码一致' : !isStrong ? '请满足密码复杂度要求' : '设置密码'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function SetupPasswordPage() {
|
||
return (
|
||
<Suspense fallback={<div style={styles.loadingFallback}>加载中...</div>}>
|
||
<SetupPasswordForm />
|
||
</Suspense>
|
||
)
|
||
}
|
||
|
||
const styles: Record<string, React.CSSProperties> = {
|
||
container: {
|
||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||
minHeight: '100vh', padding: '20px',
|
||
background: '#f1f5f9',
|
||
},
|
||
loadingFallback: {
|
||
display: 'flex', justifyContent: 'center', alignItems: 'center',
|
||
minHeight: '100vh', color: '#64748b', fontSize: '14px',
|
||
},
|
||
card: {
|
||
background: '#fff', borderRadius: '12px',
|
||
padding: '40px', maxWidth: '420px', width: '100%',
|
||
boxShadow: '0 2px 12px rgba(0,0,0,0.08)',
|
||
},
|
||
title: {
|
||
fontSize: '22px', fontWeight: 700, margin: '0 0 12px',
|
||
color: '#0f172a',
|
||
},
|
||
text: {
|
||
fontSize: '14px', color: '#475569', margin: '0 0 24px', lineHeight: 1.6,
|
||
},
|
||
label: {
|
||
display: 'block', marginBottom: '16px',
|
||
fontSize: '13px', fontWeight: 500, color: '#334155',
|
||
},
|
||
input: {
|
||
display: 'block', width: '100%', marginTop: '6px',
|
||
padding: '10px 14px', borderRadius: '8px',
|
||
border: '1px solid #cbd5e1', background: '#fff',
|
||
color: '#0f172a', fontSize: '14px', boxSizing: 'border-box' as any,
|
||
outline: 'none',
|
||
},
|
||
complexityPanel: {
|
||
marginTop: 10, display: 'flex', flexDirection: 'column', gap: 4,
|
||
},
|
||
complexityHint: {
|
||
fontSize: 12, color: '#64748b', margin: '0 0 2px',
|
||
},
|
||
checkItem: {
|
||
display: 'flex', alignItems: 'center', gap: 6, fontSize: 12,
|
||
},
|
||
btn: {
|
||
width: '100%', padding: '12px', background: '#2563eb',
|
||
color: '#fff', border: 'none', borderRadius: '8px',
|
||
fontSize: '15px', fontWeight: 600,
|
||
marginTop: '8px',
|
||
},
|
||
error: {
|
||
background: '#fef2f2', color: '#dc2626', fontSize: '13px',
|
||
padding: '10px 14px', borderRadius: '8px', marginBottom: '16px',
|
||
},
|
||
link: {
|
||
display: 'inline-block', marginTop: '16px', color: '#2563eb',
|
||
textDecoration: 'none', fontSize: '14px', fontWeight: 500,
|
||
},
|
||
}
|