feat(ui): add password strength validation and toggle visibility in registration and reset password forms (#773)

Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
Ember
2025-11-09 00:36:28 +08:00
committed by GitHub
parent 90d09e63e5
commit 4667c3bf00
8 changed files with 251 additions and 85 deletions
+10
View File
@@ -16,6 +16,7 @@
"lucide-react": "^0.552.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-password-checklist": "^1.8.1",
"recharts": "^2.15.2",
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
@@ -6982,6 +6983,15 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-password-checklist": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/react-password-checklist/-/react-password-checklist-1.8.1.tgz",
"integrity": "sha512-QHIU/OejxoH4/cIfYLHaHLb+yYc8mtL0Vr4HTmULxQg3ZNdI9Ni/yYf7pwLBgsUh4sseKCV/GzzYHWpHqejTGw==",
"license": "MIT",
"peerDependencies": {
"react": ">16.0.0-alpha || >17.0.0-alpha || >18.0.0-alpha"
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+1
View File
@@ -22,6 +22,7 @@
"lucide-react": "^0.552.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-password-checklist": "^1.8.1",
"recharts": "^2.15.2",
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
+24 -20
View File
@@ -3,6 +3,8 @@ import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import HeaderBar from './landing/HeaderBar'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from './ui/input'
export function LoginPage() {
const { language } = useLanguage()
@@ -10,6 +12,7 @@ export function LoginPage() {
const [step, setStep] = useState<'login' | 'otp'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [error, setError] = useState('')
@@ -172,16 +175,10 @@ export function LoginPage() {
>
{t('email', language)}
</label>
<input
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
@@ -194,19 +191,26 @@ export function LoginPage() {
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder={t('passwordPlaceholder', language)}
required
/>
<button
type="button"
aria-label={showPassword ? '隐藏密码' : '显示密码'}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<div className="text-right mt-2">
<button
type="button"
+102 -39
View File
@@ -4,6 +4,9 @@ import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { getSystemConfig } from '../lib/config'
import HeaderBar from './landing/HeaderBar'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from './ui/input'
import PasswordChecklist from 'react-password-checklist'
export function RegisterPage() {
const { language } = useLanguage()
@@ -22,6 +25,9 @@ export function RegisterPage() {
const [qrCodeURL, setQrCodeURL] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式
@@ -38,13 +44,10 @@ export function RegisterPage() {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError(t('passwordMismatch', language))
return
}
if (password.length < 6) {
setError(t('passwordTooShort', language))
// 客户端强校验:长度>=8,包含大小写、数字、特殊字符,且两次一致
const strong = isStrongPassword(password)
if (!strong || password !== confirmPassword) {
setError(t('passwordNotMeetRequirements', language))
return
}
@@ -149,16 +152,10 @@ export function RegisterPage() {
>
{t('email', language)}
</label>
<input
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
@@ -171,19 +168,26 @@ export function RegisterPage() {
>
{t('password', language)}
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
}}
placeholder={t('passwordPlaceholder', language)}
required
/>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pr-10"
placeholder={t('passwordPlaceholder', language)}
required
/>
<button
type="button"
aria-label={showPassword ? '隐藏密码' : '显示密码'}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<div>
@@ -193,18 +197,65 @@ export function RegisterPage() {
>
{t('confirmPassword', language)}
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: 'var(--brand-black)',
border: '1px solid var(--panel-border)',
color: 'var(--brand-light-gray)',
<div className="relative">
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pr-10"
placeholder={t('confirmPasswordPlaceholder', language)}
required
/>
<button
type="button"
aria-label={showConfirmPassword ? '隐藏密码' : '显示密码'}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowConfirmPassword((v) => !v)}
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showConfirmPassword ? (
<EyeOff size={18} />
) : (
<Eye size={18} />
)}
</button>
</div>
</div>
{/* 密码规则清单(通过才允许提交) */}
<div
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
<div
className="mb-1"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
value={password}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
placeholder={t('confirmPasswordPlaceholder', language)}
required
className="space-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
/>
</div>
@@ -256,7 +307,9 @@ export function RegisterPage() {
<button
type="submit"
disabled={loading || (betaMode && !betaCode.trim())}
disabled={
loading || (betaMode && !betaCode.trim()) || !passwordValid
}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{
background: 'var(--brand-yellow)',
@@ -500,3 +553,13 @@ export function RegisterPage() {
</div>
)
}
// 本地密码强度校验(与 UI 规则一致)
function isStrongPassword(pwd: string): boolean {
if (!pwd || pwd.length < 8) return false
const hasUpper = /[A-Z]/.test(pwd)
const hasLower = /[a-z]/.test(pwd)
const hasNumber = /\d/.test(pwd)
const hasSpecial = /[@#$%!&*?]/.test(pwd)
return hasUpper && hasLower && hasNumber && hasSpecial
}
+51 -26
View File
@@ -4,6 +4,8 @@ import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { Header } from './Header'
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
import PasswordChecklist from 'react-password-checklist'
import { Input } from './ui/input'
export function ResetPasswordPage() {
const { language } = useLanguage()
@@ -17,6 +19,7 @@ export function ResetPasswordPage() {
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [passwordValid, setPasswordValid] = useState(false)
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault()
@@ -112,16 +115,10 @@ export function ResetPasswordPage() {
>
{t('email', language)}
</label>
<input
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
placeholder={t('emailPlaceholder', language)}
required
/>
@@ -135,24 +132,20 @@ export function ResetPasswordPage() {
{t('newPassword', language)}
</label>
<div className="relative">
<input
<Input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
className="pr-10"
placeholder={t('newPasswordPlaceholder', language)}
required
minLength={6}
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
@@ -171,26 +164,22 @@ export function ResetPasswordPage() {
{t('confirmPassword', language)}
</label>
<div className="relative">
<input
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 pr-10 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
className="pr-10"
placeholder={t('confirmPasswordPlaceholder', language)}
required
minLength={6}
/>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() =>
setShowConfirmPassword(!showConfirmPassword)
}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
style={{ color: 'var(--text-secondary)' }}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
@@ -201,6 +190,42 @@ export function ResetPasswordPage() {
</div>
</div>
{/* 密码强度检查(必须通过才允许提交) */}
<div
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
<div
className="mb-1"
style={{ color: 'var(--brand-light-gray)' }}
>
{t('passwordRequirements', language)}
</div>
<PasswordChecklist
rules={[
'minLength',
'capital',
'lowercase',
'number',
'specialChar',
'match',
]}
minLength={8}
value={newPassword}
valueAgain={confirmPassword}
messages={{
minLength: t('passwordRuleMinLength', language),
capital: t('passwordRuleUppercase', language),
lowercase: t('passwordRuleLowercase', language),
number: t('passwordRuleNumber', language),
specialChar: t('passwordRuleSpecial', language),
match: t('passwordRuleMatch', language),
}}
className="space-y-1"
onChange={(isValid) => setPasswordValid(isValid)}
/>
</div>
<div>
<label
className="block text-sm font-semibold mb-2"
@@ -246,7 +271,7 @@ export function ResetPasswordPage() {
<button
type="submit"
disabled={loading || otpCode.length !== 6}
disabled={loading || otpCode.length !== 6 || !passwordValid}
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#000' }}
>
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react'
import { cn } from '../../lib/cn'
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type = 'text', ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(
'flex h-10 w-full rounded px-3 py-2 text-sm',
'bg-[var(--brand-black)] border border-[var(--panel-border)]',
'text-[var(--brand-light-gray)] focus:outline-none',
className
)}
{...props}
/>
)
}
)
Input.displayName = 'Input'
+32
View File
@@ -364,12 +364,23 @@ export const translations = {
// Login & Register
login: 'Sign In',
register: 'Sign Up',
username: 'Username',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
usernamePlaceholder: 'your username',
emailPlaceholder: 'your@email.com',
passwordPlaceholder: 'Enter your password',
confirmPasswordPlaceholder: 'Re-enter your password',
passwordRequirements: 'Password requirements',
passwordRuleMinLength: 'Minimum 8 characters',
passwordRuleUppercase: 'At least 1 uppercase letter',
passwordRuleLowercase: 'At least 1 lowercase letter',
passwordRuleNumber: 'At least 1 number',
passwordRuleSpecial: 'At least 1 special character (@#$%!&*?)',
passwordRuleMatch: 'Passwords match',
passwordNotMeetRequirements:
'Password does not meet the security requirements',
otpPlaceholder: '000000',
loginTitle: 'Sign in to your account',
registerTitle: 'Create a new account',
@@ -419,6 +430,12 @@ export const translations = {
registrationFailed: 'Registration failed',
verificationFailed: 'OTP verification failed',
invalidCredentials: 'Invalid email or password',
weak: 'Weak',
medium: 'Medium',
strong: 'Strong',
passwordStrength: 'Password strength',
passwordStrengthHint:
'Use at least 8 characters with mix of letters, numbers and symbols',
passwordMismatch: 'Passwords do not match',
emailRequired: 'Email is required',
passwordRequired: 'Password is required',
@@ -1089,12 +1106,22 @@ export const translations = {
// Login & Register
login: '登录',
register: '注册',
username: '用户名',
email: '邮箱',
password: '密码',
confirmPassword: '确认密码',
usernamePlaceholder: '请输入用户名',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码(至少6位)',
confirmPasswordPlaceholder: '请再次输入密码',
passwordRequirements: '密码要求',
passwordRuleMinLength: '至少 8 位',
passwordRuleUppercase: '至少 1 个大写字母',
passwordRuleLowercase: '至少 1 个小写字母',
passwordRuleNumber: '至少 1 个数字',
passwordRuleSpecial: '至少 1 个特殊字符(@#$%!&*?',
passwordRuleMatch: '两次密码一致',
passwordNotMeetRequirements: '密码不符合安全要求',
otpPlaceholder: '000000',
loginTitle: '登录到您的账户',
registerTitle: '创建新账户',
@@ -1140,6 +1167,11 @@ export const translations = {
registrationFailed: '注册失败',
verificationFailed: 'OTP验证失败',
invalidCredentials: '邮箱或密码错误',
weak: '弱',
medium: '中',
strong: '强',
passwordStrength: '密码强度',
passwordStrengthHint: '建议至少8位,包含大小写、数字和符号',
passwordMismatch: '两次输入的密码不一致',
emailRequired: '请输入邮箱',
passwordRequired: '请输入密码',
+7
View File
@@ -0,0 +1,7 @@
import { type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}