mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-07 13:00:59 +08:00
refactor: restructure project directories for better modularity
- Delete llm/ dead code (3 files, zero references) - Split mcp/ into sub-packages: mcp/provider/ (8 providers) and mcp/payment/ (4 payment clients) with registry pattern - Export Client internal fields and ClientHooks interface for sub-package access - Split api/server.go (3892 lines) into 8 domain-specific handler files - Split trader/auto_trader.go (2296 lines) into 5 focused files - Reorganize web/src/components/ flat files into auth/, charts/, trader/, common/, modals/, backtest/ subdirectories - Update all consumer imports to use registry-based provider creation
This commit is contained in:
134
web/src/components/auth/LoginPage.tsx
Normal file
134
web/src/components/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem('from401') === 'true') {
|
||||
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
|
||||
setExpiredToastId(id)
|
||||
sessionStorage.removeItem('from401')
|
||||
}
|
||||
}, [language])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
const result = await login(email, password)
|
||||
setLoading(false)
|
||||
if (result.success) {
|
||||
if (expiredToastId) toast.dismiss(expiredToastId)
|
||||
} else {
|
||||
const msg = result.message || t('loginFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<div className="flex-1 flex items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-sm">
|
||||
|
||||
{/* Logo + Title */}
|
||||
<div className="text-center mb-10">
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-3 bg-nofx-gold/15 rounded-full blur-2xl" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-14 h-14 relative z-10" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1.5">Welcome back</h1>
|
||||
<p className="text-zinc-500 text-sm">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-8 shadow-2xl">
|
||||
<form onSubmit={handleLogin} className="space-y-5">
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-2">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-zinc-400">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.href = '/reset-password'}
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-950/80 border border-zinc-700/80 rounded-xl px-4 py-3 pr-11 text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/60 focus:ring-1 focus:ring-nofx-gold/30 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-nofx-gold hover:bg-yellow-400 active:scale-[0.98] text-black font-semibold py-3 rounded-xl text-sm transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-2"
|
||||
>
|
||||
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
181
web/src/components/auth/LoginRequiredOverlay.tsx
Normal file
181
web/src/components/auth/LoginRequiredOverlay.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { LogIn, UserPlus, X, AlertTriangle, Terminal } from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
|
||||
interface LoginRequiredOverlayProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
featureName?: string
|
||||
}
|
||||
|
||||
export function LoginRequiredOverlay({ isOpen, onClose, featureName }: LoginRequiredOverlayProps) {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const texts = {
|
||||
zh: {
|
||||
title: '系统访问受限',
|
||||
subtitle: featureName ? `访问「${featureName}」需要更高权限` : '此模块需要授权访问',
|
||||
description: '初始化身份验证协议以解锁完整系统功能:AI 交易员配置、策略市场数据流、回测模拟核心。',
|
||||
benefits: [
|
||||
'AI 交易员控制权',
|
||||
'高频策略核心市场',
|
||||
'历史数据回测引擎',
|
||||
'全系统数据可视化'
|
||||
],
|
||||
login: '执行登录指令',
|
||||
register: '注册新用户 ID',
|
||||
later: '中止操作'
|
||||
},
|
||||
en: {
|
||||
title: 'SYSTEM ACCESS DENIED',
|
||||
subtitle: featureName ? `Module "${featureName}" requires elevated privileges` : 'Authorization required for this module',
|
||||
description: 'Initialize authentication protocol to unlock full system capabilities: AI Trader configuration, Strategy Market data streams, and Backtest Simulation core.',
|
||||
benefits: [
|
||||
'AI Trader Control',
|
||||
'HFT Strategy Market',
|
||||
'Historical Backtest Engine',
|
||||
'Full System Visualization'
|
||||
],
|
||||
login: 'EXECUTE LOGIN',
|
||||
register: 'REGISTER NEW ID',
|
||||
later: 'ABORT'
|
||||
},
|
||||
id: {
|
||||
title: 'AKSES SISTEM DITOLAK',
|
||||
subtitle: featureName ? `Modul "${featureName}" memerlukan hak akses lebih tinggi` : 'Otorisasi diperlukan untuk modul ini',
|
||||
description: 'Inisialisasi protokol autentikasi untuk membuka kemampuan sistem penuh: konfigurasi Trader AI, aliran data Pasar Strategi, dan inti Simulasi Backtest.',
|
||||
benefits: [
|
||||
'Kontrol Trader AI',
|
||||
'Pasar Strategi HFT',
|
||||
'Mesin Backtest Historis',
|
||||
'Visualisasi Sistem Penuh'
|
||||
],
|
||||
login: 'JALANKAN LOGIN',
|
||||
register: 'DAFTAR ID BARU',
|
||||
later: 'BATALKAN'
|
||||
}
|
||||
}
|
||||
|
||||
const t = texts[language]
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50"
|
||||
>
|
||||
<DeepVoidBackground
|
||||
className="w-full h-full bg-nofx-bg/95 backdrop-blur-md flex items-center justify-center p-4 text-nofx-text"
|
||||
disableAnimation
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
|
||||
className="relative max-w-md w-full overflow-hidden bg-nofx-bg border border-nofx-gold/30 shadow-neon rounded-sm group font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Terminal Window Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">auth_protocol.exe</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-nofx-text-muted hover:text-nofx-danger transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="p-8 relative">
|
||||
{/* Background Grid */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808008_1px,transparent_1px),linear-gradient(to_bottom,#80808008_1px,transparent_1px)] bg-[size:14px_14px] pointer-events-none"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Flashing Access Denied */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">{language === 'zh' ? '访问被拒绝' : 'ACCESS DENIED'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">{t.title}</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">{t.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
<p className="text-xs text-nofx-text-muted leading-relaxed font-mono">
|
||||
<span className="text-green-500 mr-2">$</span>
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{t.benefits.map((benefit, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[10px] text-nofx-text-muted uppercase tracking-wide">
|
||||
<span className="text-nofx-gold">✓</span> {benefit}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{t.login}</span>
|
||||
<span className="opacity-0 group-hover:opacity-100 transition-opacity -ml-2 group-hover:ml-0">-></span>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{t.register}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] text-nofx-text-muted hover:text-nofx-danger uppercase tracking-widest hover:underline decoration-red-500/30"
|
||||
>
|
||||
[ {t.later} ]
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Corner Accents */}
|
||||
<div className="absolute top-0 right-0 w-2 h-2 border-t border-r border-nofx-gold"></div>
|
||||
<div className="absolute bottom-0 left-0 w-2 h-2 border-b border-l border-nofx-gold"></div>
|
||||
|
||||
</motion.div>
|
||||
</DeepVoidBackground>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
377
web/src/components/auth/RegisterPage.test.tsx
Normal file
377
web/src/components/auth/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* PR #XXX 测试: 修复密码校验不一致的问题
|
||||
*
|
||||
* 问题:RegisterPage 中存在两处密码校验逻辑:
|
||||
* 1. PasswordChecklist 组件提供的可视化校验
|
||||
* 2. 自定义的 isStrongPassword 函数
|
||||
* 这导致校验规则可能不一致
|
||||
*
|
||||
* 修复:移除重复的 isStrongPassword 函数,统一使用 PasswordChecklist 的校验结果
|
||||
*
|
||||
* 本测试专注于验证密码校验逻辑的一致性,确保:
|
||||
* 1. 移除了重复的 isStrongPassword 函数
|
||||
* 2. 使用统一的 PasswordChecklist 校验
|
||||
* 3. 特殊字符规则在正常显示和错误提示中保持一致
|
||||
*/
|
||||
|
||||
describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
/**
|
||||
* 测试密码校验规则逻辑
|
||||
* 这些测试验证密码校验的核心逻辑,与 PasswordChecklist 组件的规则一致
|
||||
*/
|
||||
describe('password validation rules', () => {
|
||||
it('should validate minimum 8 characters', () => {
|
||||
const password = 'Short1!'
|
||||
const isValid = password.length >= 8
|
||||
expect(isValid).toBe(false)
|
||||
|
||||
const validPassword = 'LongPass1!'
|
||||
const isValidPassword = validPassword.length >= 8
|
||||
expect(isValidPassword).toBe(true)
|
||||
})
|
||||
|
||||
it('should require uppercase letter', () => {
|
||||
const hasUppercase = (pwd: string) => /[A-Z]/.test(pwd)
|
||||
|
||||
expect(hasUppercase('lowercase123!')).toBe(false)
|
||||
expect(hasUppercase('Uppercase123!')).toBe(true)
|
||||
expect(hasUppercase('ALLCAPS123!')).toBe(true)
|
||||
})
|
||||
|
||||
it('should require lowercase letter', () => {
|
||||
const hasLowercase = (pwd: string) => /[a-z]/.test(pwd)
|
||||
|
||||
expect(hasLowercase('UPPERCASE123!')).toBe(false)
|
||||
expect(hasLowercase('Lowercase123!')).toBe(true)
|
||||
expect(hasLowercase('alllower123!')).toBe(true)
|
||||
})
|
||||
|
||||
it('should require number', () => {
|
||||
const hasNumber = (pwd: string) => /\d/.test(pwd)
|
||||
|
||||
expect(hasNumber('NoNumber!')).toBe(false)
|
||||
expect(hasNumber('HasNumber1!')).toBe(true)
|
||||
expect(hasNumber('Multiple123!')).toBe(true)
|
||||
})
|
||||
|
||||
it('should require special character from allowed set', () => {
|
||||
// 根据 RegisterPage.tsx 中的设置,特殊字符正则为 /[@#$%!&*?]/
|
||||
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
|
||||
|
||||
expect(hasSpecialChar('NoSpecial123')).toBe(false)
|
||||
expect(hasSpecialChar('HasAt123@')).toBe(true)
|
||||
expect(hasSpecialChar('HasHash123#')).toBe(true)
|
||||
expect(hasSpecialChar('HasDollar123$')).toBe(true)
|
||||
expect(hasSpecialChar('HasPercent123%')).toBe(true)
|
||||
expect(hasSpecialChar('HasExclaim123!')).toBe(true)
|
||||
expect(hasSpecialChar('HasAmpersand123&')).toBe(true)
|
||||
expect(hasSpecialChar('HasStar123*')).toBe(true)
|
||||
expect(hasSpecialChar('HasQuestion123?')).toBe(true)
|
||||
|
||||
// 不在允许列表中的特殊字符应该不通过
|
||||
expect(hasSpecialChar('HasCaret123^')).toBe(false)
|
||||
expect(hasSpecialChar('HasTilde123~')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate passwords match', () => {
|
||||
const password = 'StrongPass123!'
|
||||
const confirmPassword1 = 'StrongPass123!'
|
||||
const confirmPassword2 = 'DifferentPass123!'
|
||||
|
||||
expect(password === confirmPassword1).toBe(true)
|
||||
expect(password === confirmPassword2).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试完整的密码强度校验
|
||||
* 模拟 PasswordChecklist 的完整校验逻辑
|
||||
*/
|
||||
describe('complete password strength validation', () => {
|
||||
const validatePassword = (
|
||||
pwd: string,
|
||||
confirmPwd: string
|
||||
): {
|
||||
minLength: boolean
|
||||
hasUppercase: boolean
|
||||
hasLowercase: boolean
|
||||
hasNumber: boolean
|
||||
hasSpecialChar: boolean
|
||||
match: boolean
|
||||
isValid: boolean
|
||||
} => {
|
||||
const minLength = pwd.length >= 8
|
||||
const hasUppercase = /[A-Z]/.test(pwd)
|
||||
const hasLowercase = /[a-z]/.test(pwd)
|
||||
const hasNumber = /\d/.test(pwd)
|
||||
const hasSpecialChar = /[@#$%!&*?]/.test(pwd)
|
||||
const match = pwd === confirmPwd
|
||||
|
||||
return {
|
||||
minLength,
|
||||
hasUppercase,
|
||||
hasLowercase,
|
||||
hasNumber,
|
||||
hasSpecialChar,
|
||||
match,
|
||||
isValid:
|
||||
minLength &&
|
||||
hasUppercase &&
|
||||
hasLowercase &&
|
||||
hasNumber &&
|
||||
hasSpecialChar &&
|
||||
match,
|
||||
}
|
||||
}
|
||||
|
||||
it('should reject password with only lowercase', () => {
|
||||
const result = validatePassword('lowercase123!', 'lowercase123!')
|
||||
expect(result.hasLowercase).toBe(true)
|
||||
expect(result.hasUppercase).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject password with only uppercase', () => {
|
||||
const result = validatePassword('UPPERCASE123!', 'UPPERCASE123!')
|
||||
expect(result.hasUppercase).toBe(true)
|
||||
expect(result.hasLowercase).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject password without numbers', () => {
|
||||
const result = validatePassword('NoNumber!', 'NoNumber!')
|
||||
expect(result.hasNumber).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject password without special characters', () => {
|
||||
const result = validatePassword('NoSpecial123', 'NoSpecial123')
|
||||
expect(result.hasSpecialChar).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject password less than 8 characters', () => {
|
||||
const result = validatePassword('Short1!', 'Short1!')
|
||||
expect(result.minLength).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject when passwords do not match', () => {
|
||||
const result = validatePassword('StrongPass123!', 'DifferentPass123!')
|
||||
expect(result.match).toBe(false)
|
||||
expect(result.isValid).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept strong password meeting all requirements', () => {
|
||||
const result = validatePassword('StrongPass123!', 'StrongPass123!')
|
||||
expect(result.minLength).toBe(true)
|
||||
expect(result.hasUppercase).toBe(true)
|
||||
expect(result.hasLowercase).toBe(true)
|
||||
expect(result.hasNumber).toBe(true)
|
||||
expect(result.hasSpecialChar).toBe(true)
|
||||
expect(result.match).toBe(true)
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept password with exactly 8 characters', () => {
|
||||
const result = validatePassword('Pass123!', 'Pass123!')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept password with multiple special characters', () => {
|
||||
const result = validatePassword('Pass123!@#', 'Pass123!@#')
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
|
||||
it('should accept very long password', () => {
|
||||
const longPassword = 'VeryLongStrongPassword123!@#$%'
|
||||
const result = validatePassword(longPassword, longPassword)
|
||||
expect(result.isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试特殊字符一致性
|
||||
* 确保在 RegisterPage 的正常显示(第 229-251 行)和错误提示(第 300-323 行)中
|
||||
* 使用相同的特殊字符正则 /[@#$%!&*?]/
|
||||
*/
|
||||
describe('special character consistency', () => {
|
||||
it('should use consistent special character regex across all validations', () => {
|
||||
// RegisterPage 中两处 PasswordChecklist 都应该使用相同的 specialCharsRegex
|
||||
const specialCharsRegex = /[@#$%!&*?]/
|
||||
|
||||
// 测试允许的特殊字符
|
||||
const validSpecialChars = ['@', '#', '$', '%', '!', '&', '*', '?']
|
||||
validSpecialChars.forEach((char) => {
|
||||
expect(specialCharsRegex.test(char)).toBe(true)
|
||||
})
|
||||
|
||||
// 测试不允许的特殊字符
|
||||
const invalidSpecialChars = ['^', '~', '`', '(', ')', '-', '_', '=', '+']
|
||||
invalidSpecialChars.forEach((char) => {
|
||||
expect(specialCharsRegex.test(char)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should validate all allowed special characters in passwords', () => {
|
||||
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
|
||||
const validPasswords = [
|
||||
'Password123@',
|
||||
'Password123#',
|
||||
'Password123$',
|
||||
'Password123%',
|
||||
'Password123!',
|
||||
'Password123&',
|
||||
'Password123*',
|
||||
'Password123?',
|
||||
]
|
||||
|
||||
validPasswords.forEach((pwd) => {
|
||||
expect(hasSpecialChar(pwd)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject passwords with non-allowed special characters', () => {
|
||||
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
|
||||
const invalidPasswords = [
|
||||
'Password123^',
|
||||
'Password123~',
|
||||
'Password123`',
|
||||
'Password123(',
|
||||
'Password123)',
|
||||
'Password123-',
|
||||
'Password123_',
|
||||
'Password123=',
|
||||
'Password123+',
|
||||
]
|
||||
|
||||
invalidPasswords.forEach((pwd) => {
|
||||
expect(hasSpecialChar(pwd)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试边界情况
|
||||
*/
|
||||
describe('edge cases', () => {
|
||||
const validatePassword = (pwd: string, confirmPwd: string): boolean => {
|
||||
const minLength = pwd.length >= 8
|
||||
const hasUppercase = /[A-Z]/.test(pwd)
|
||||
const hasLowercase = /[a-z]/.test(pwd)
|
||||
const hasNumber = /\d/.test(pwd)
|
||||
const hasSpecialChar = /[@#$%!&*?]/.test(pwd)
|
||||
const match = pwd === confirmPwd
|
||||
|
||||
return (
|
||||
minLength &&
|
||||
hasUppercase &&
|
||||
hasLowercase &&
|
||||
hasNumber &&
|
||||
hasSpecialChar &&
|
||||
match
|
||||
)
|
||||
}
|
||||
|
||||
it('should handle exactly 8 character password', () => {
|
||||
expect(validatePassword('Pass123!', 'Pass123!')).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle very long password', () => {
|
||||
const longPassword = 'VeryLongStrongPassword123!@#$%^&*()_+'
|
||||
expect(validatePassword(longPassword, longPassword)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle password with all allowed special characters', () => {
|
||||
const password = 'Pass123@#$%!&*?'
|
||||
expect(validatePassword(password, password)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle password with consecutive numbers', () => {
|
||||
const password = 'Password123456789!'
|
||||
expect(validatePassword(password, password)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle password with consecutive special characters', () => {
|
||||
const password = 'Pass123!@#$%'
|
||||
expect(validatePassword(password, password)).toBe(true)
|
||||
})
|
||||
|
||||
it('should be case sensitive for matching', () => {
|
||||
expect(validatePassword('Password123!', 'password123!')).toBe(false)
|
||||
expect(validatePassword('password123!', 'Password123!')).toBe(false)
|
||||
})
|
||||
|
||||
it('should not accept whitespace as special character', () => {
|
||||
const hasSpecialChar = /[@#$%!&*?]/.test('Password123 ')
|
||||
expect(hasSpecialChar).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试重构后的一致性
|
||||
* 确保移除 isStrongPassword 函数后,所有校验都通过 PasswordChecklist
|
||||
*/
|
||||
describe('refactoring consistency verification', () => {
|
||||
it('should have removed duplicate isStrongPassword function', () => {
|
||||
// 这个测试验证重构的意图:
|
||||
// 在重构之前,存在一个 isStrongPassword 函数
|
||||
// 重构后应该移除该函数,只使用 PasswordChecklist 的校验
|
||||
|
||||
// 我们通过模拟 PasswordChecklist 的逻辑来验证一致性
|
||||
const passwordChecklistValidation = (pwd: string, confirm: string) => {
|
||||
return {
|
||||
minLength: pwd.length >= 8,
|
||||
capital: /[A-Z]/.test(pwd),
|
||||
lowercase: /[a-z]/.test(pwd),
|
||||
number: /\d/.test(pwd),
|
||||
specialChar: /[@#$%!&*?]/.test(pwd),
|
||||
match: pwd === confirm,
|
||||
}
|
||||
}
|
||||
|
||||
// 测试几个密码
|
||||
const testCases = [
|
||||
{ pwd: 'Weak', confirm: 'Weak', shouldPass: false },
|
||||
{ pwd: 'StrongPass123!', confirm: 'StrongPass123!', shouldPass: true },
|
||||
{ pwd: 'NoNumber!', confirm: 'NoNumber!', shouldPass: false },
|
||||
{ pwd: 'Pass123!', confirm: 'Pass123!', shouldPass: true },
|
||||
]
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const result = passwordChecklistValidation(
|
||||
testCase.pwd,
|
||||
testCase.confirm
|
||||
)
|
||||
const isValid = Object.values(result).every((v) => v === true)
|
||||
expect(isValid).toBe(testCase.shouldPass)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use consistent validation logic across the component', () => {
|
||||
// 验证校验逻辑的一致性
|
||||
const validation1 = {
|
||||
minLength: 8,
|
||||
requireCapital: true,
|
||||
requireLowercase: true,
|
||||
requireNumber: true,
|
||||
requireSpecialChar: true,
|
||||
specialCharsRegex: /[@#$%!&*?]/,
|
||||
}
|
||||
|
||||
// 在 RegisterPage 的正常显示和错误提示中应该使用相同的配置
|
||||
const validation2 = {
|
||||
minLength: 8,
|
||||
requireCapital: true,
|
||||
requireLowercase: true,
|
||||
requireNumber: true,
|
||||
requireSpecialChar: true,
|
||||
specialCharsRegex: /[@#$%!&*?]/,
|
||||
}
|
||||
|
||||
expect(validation1).toEqual(validation2)
|
||||
})
|
||||
})
|
||||
})
|
||||
314
web/src/components/auth/RegisterPage.tsx
Normal file
314
web/src/components/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
import { toast } from 'sonner'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { getSystemConfig } from '../../lib/config'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { RegistrationDisabled } from './RegistrationDisabled'
|
||||
import { WhitelistFullPage } from '../common/WhitelistFullPage'
|
||||
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage()
|
||||
const { register } = useAuth()
|
||||
const [view, setView] = useState<'register' | 'whitelist-full'>('register')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [betaCode, setBetaCode] = useState('')
|
||||
const [betaMode, setBetaMode] = useState(false)
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true)
|
||||
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(() => {
|
||||
getSystemConfig()
|
||||
.then((config) => {
|
||||
setBetaMode(config.beta_mode || false)
|
||||
setRegistrationEnabled(config.initialized === false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch system config:', err)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return <RegistrationDisabled />
|
||||
}
|
||||
|
||||
if (view === 'whitelist-full') {
|
||||
return <WhitelistFullPage onBack={() => setView('register')} />
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!passwordValid) {
|
||||
setError(t('passwordNotMeetRequirements', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (betaMode && !betaCode.trim()) {
|
||||
setError('内测期间,注册需要提供内测码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await register(email, password, betaCode.trim() || undefined)
|
||||
|
||||
const isWhitelistError = (msg: string) => {
|
||||
const lowerMsg = msg.toLowerCase()
|
||||
return (
|
||||
lowerMsg.includes('whitelist') ||
|
||||
lowerMsg.includes('capacity') ||
|
||||
lowerMsg.includes('limit') ||
|
||||
lowerMsg.includes('permission denied') ||
|
||||
lowerMsg.includes('not on whitelist')
|
||||
)
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
const msg = result.message || t('registrationFailed', language)
|
||||
if (isWhitelistError(msg)) {
|
||||
setView('whitelist-full')
|
||||
return
|
||||
}
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
// success path is handled in AuthContext (auto login + navigation)
|
||||
} catch (e) {
|
||||
console.error('Registration error:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : 'Registration failed due to server error'
|
||||
const lowerMsg = errorMsg.toLowerCase()
|
||||
if (
|
||||
lowerMsg.includes('whitelist') ||
|
||||
lowerMsg.includes('capacity') ||
|
||||
lowerMsg.includes('limit') ||
|
||||
lowerMsg.includes('permission denied') ||
|
||||
lowerMsg.includes('not on whitelist')
|
||||
) {
|
||||
setView('whitelist-full')
|
||||
return
|
||||
}
|
||||
setError(errorMsg)
|
||||
toast.error(errorMsg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
|
||||
<div className="w-full max-w-lg relative z-10 px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">< ABORT_REGISTRATION</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-16 h-16 object-contain relative z-10 opacity-90" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
|
||||
<span className="text-nofx-gold">NEW_USER</span> ONBOARDING
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
|
||||
Initializing Registration Sequence...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-zinc-800 rounded-lg overflow-hidden shadow-2xl relative group">
|
||||
<div className="absolute inset-0 bg-zinc-900/50 opacity-0 group-hover:opacity-100 transition duration-700 pointer-events-none"></div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900/80 border-b border-zinc-800">
|
||||
<div className="flex gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
|
||||
onClick={() => (window.location.href = '/')}
|
||||
title="Close / Return Home"
|
||||
></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500/50"></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 font-mono flex items-center gap-1">
|
||||
<span className="text-emerald-500">➜</span> setup_account.sh
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 relative">
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>System Check: <span className="text-emerald-500">READY</span></span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span>Mode: {betaMode ? 'CLOSED_BETA CA1' : 'PUBLIC'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('email', language)}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono"
|
||||
placeholder="user@nofx.os"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('password', language)}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">{t('confirmPassword', language)}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 p-3 rounded border border-zinc-800/50">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2 font-bold flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-zinc-500"></div>
|
||||
Password Strength Protocol
|
||||
</div>
|
||||
<div className="text-xs font-mono text-zinc-400">
|
||||
<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),
|
||||
}}
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-1"
|
||||
onChange={(isValid) => setPasswordValid(isValid)}
|
||||
iconSize={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{betaMode && (
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1 font-bold">Priority Access Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={betaCode}
|
||||
onChange={(e) => setBetaCode(e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase())}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
|
||||
placeholder="XXXXXX"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">* CASE SENSITIVE ALPHANUMERIC</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
|
||||
[REGISTRATION_ERROR]: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim()) || !passwordValid}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">INITIALIZING...</span>
|
||||
) : (
|
||||
<>
|
||||
<span>CREATE_ACCOUNT</span>
|
||||
<span className="group-hover:translate-x-1 transition-transform">-></span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 p-3 flex justify-between items-center text-[10px] font-mono text-zinc-600 border-t border-zinc-800">
|
||||
<div>ENCRYPTION: AES-256</div>
|
||||
<div>SECURE_REGISTRY</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8 space-y-4">
|
||||
<p className="text-xs font-mono text-zinc-500">
|
||||
EXISTING_OPERATOR?{' '}
|
||||
<button
|
||||
onClick={() => (window.location.href = '/login')}
|
||||
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
|
||||
>
|
||||
ACCESS TERMINAL
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
|
||||
>
|
||||
[ ABORT_REGISTRATION_RETURN_HOME ]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DeepVoidBackground>
|
||||
)
|
||||
}
|
||||
103
web/src/components/auth/RegistrationDisabled.test.tsx
Normal file
103
web/src/components/auth/RegistrationDisabled.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { RegistrationDisabled } from './RegistrationDisabled'
|
||||
import { LanguageProvider } from '../../contexts/LanguageContext'
|
||||
|
||||
// Mock useLanguage hook
|
||||
vi.mock('../../contexts/LanguageContext', async () => {
|
||||
const actual = await vi.importActual('../../contexts/LanguageContext')
|
||||
return {
|
||||
...actual,
|
||||
useLanguage: () => ({ language: 'en' }),
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* RegistrationDisabled Component Tests
|
||||
*
|
||||
* Tests the component that displays when registration is disabled
|
||||
* This is part of the registration toggle feature
|
||||
*/
|
||||
describe('RegistrationDisabled Component', () => {
|
||||
const renderComponent = () => {
|
||||
return render(
|
||||
<LanguageProvider>
|
||||
<RegistrationDisabled />
|
||||
</LanguageProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the component without errors', () => {
|
||||
const { container } = renderComponent()
|
||||
expect(container).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should display the NoFx logo', () => {
|
||||
renderComponent()
|
||||
const logo = screen.getByAltText('NoFx Logo')
|
||||
expect(logo).toBeTruthy()
|
||||
expect(logo.getAttribute('src')).toBe('/icons/nofx.svg')
|
||||
})
|
||||
|
||||
it('should display registration closed heading', () => {
|
||||
renderComponent()
|
||||
const heading = screen.getByText('Registration Closed')
|
||||
expect(heading).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should display registration closed message', () => {
|
||||
renderComponent()
|
||||
const message = screen.getByText(/User registration is currently disabled/i)
|
||||
expect(message).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should display back to login button', () => {
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', { name: /back to login/i })
|
||||
expect(button).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate to login page when button is clicked', () => {
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState')
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
|
||||
renderComponent()
|
||||
const button = screen.getByRole('button', { name: /back to login/i })
|
||||
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(pushStateSpy).toHaveBeenCalledWith({}, '', '/login')
|
||||
expect(dispatchEventSpy).toHaveBeenCalled()
|
||||
|
||||
pushStateSpy.mockRestore()
|
||||
dispatchEventSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should have correct background color', () => {
|
||||
const { container } = renderComponent()
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
// Browser converts hex to rgb
|
||||
expect(mainDiv.style.background).toMatch(/rgb\(11,\s*14,\s*17\)|#0B0E11/i)
|
||||
})
|
||||
|
||||
it('should have correct text color', () => {
|
||||
const { container } = renderComponent()
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
// Browser converts hex to rgb
|
||||
expect(mainDiv.style.color).toMatch(/rgb\(234,\s*236,\s*239\)|#EAECEF/i)
|
||||
})
|
||||
|
||||
it('should have centered layout', () => {
|
||||
const { container } = renderComponent()
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
expect(mainDiv.className).toContain('flex')
|
||||
expect(mainDiv.className).toContain('items-center')
|
||||
expect(mainDiv.className).toContain('justify-center')
|
||||
})
|
||||
})
|
||||
})
|
||||
39
web/src/components/auth/RegistrationDisabled.tsx
Normal file
39
web/src/components/auth/RegistrationDisabled.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export function RegistrationDisabled() {
|
||||
const { language } = useLanguage()
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
>
|
||||
<div className="text-center max-w-md px-6">
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
className="w-16 h-16 mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-2xl font-semibold mb-3">
|
||||
{t('registrationClosed', language)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t('registrationClosedMessage', language)}
|
||||
</p>
|
||||
<button
|
||||
className="mt-6 px-4 py-2 rounded text-sm font-semibold transition-colors hover:opacity-90"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
onClick={handleBackToLogin}
|
||||
>
|
||||
{t('backToLogin', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
261
web/src/components/auth/ResetPasswordPage.tsx
Normal file
261
web/src/components/auth/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { Header } from '../common/Header'
|
||||
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
import { Input } from '../ui/input'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { language } = useLanguage()
|
||||
const { resetPassword } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
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()
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
|
||||
// 验证两次密码是否一致
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t('passwordMismatch', language))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const result = await resetPassword(email, newPassword)
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true)
|
||||
toast.success(t('resetPasswordSuccess', language) || '重置成功')
|
||||
// 3秒后跳转到登录页面
|
||||
setTimeout(() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, 3000)
|
||||
} else {
|
||||
const msg = result.message || t('resetPasswordFailed', language)
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
||||
<Header simple />
|
||||
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ minHeight: 'calc(100vh - 80px)' }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.pushState({}, '', '/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('backToLogin', language)}
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
|
||||
>
|
||||
<KeyRound className="w-8 h-8" style={{ color: '#F0B90B' }} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
{t('resetPasswordTitle', language)}
|
||||
</h1>
|
||||
<p className="text-sm mt-2" style={{ color: '#848E9C' }}>
|
||||
使用邮箱和新密码重置账户密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset Password Form */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<p
|
||||
className="text-lg font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('resetPasswordSuccess', language)}
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
3秒后将自动跳转到登录页面...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleResetPassword} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('newPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('newPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
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" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<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"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
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" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</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={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>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="text-sm px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !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' }}
|
||||
>
|
||||
{loading
|
||||
? t('loading', language)
|
||||
: t('resetPasswordButton', language)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user