- {/* Mobile Navigation Tabs - Show all tabs */}
- {(() => {
- const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
- { page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
- { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
- { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
- { page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
- { page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
- { page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
- { page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
- { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
- ]
-
- const handleMobileNavClick = (tab: typeof navTabs[0]) => {
- if (tab.requiresAuth && !isLoggedIn) {
- onLoginRequired?.(tab.label)
- setMobileMenuOpen(false)
- return
- }
- if (onPageChange) {
- onPageChange(tab.page)
- }
- navigate(tab.path)
- setMobileMenuOpen(false)
- }
-
- return navTabs.map((tab) => (
-
- ))
- })()}
-
- {/* Original Navigation Items - Only on home page */}
- {isHomePage &&
- [
- { key: 'features', label: t('features', language) },
- { key: 'howItWorks', label: t('howItWorks', language) },
- ].map((item) => (
-
- {item.label}
-
- ))}
-
- {/* Social Links - Mobile */}
-
+ {/* Navigation Links */}
+
+ {(() => {
+ const navTabs: { page: Page; path: string; label: string; requiresAuth: boolean }[] = [
+ { page: 'strategy-market', path: '/strategy-market', label: language === 'zh' ? '策略市场' : 'Market', requiresAuth: true },
+ { page: 'traders', path: '/traders', label: t('configNav', language), requiresAuth: true },
+ { page: 'trader', path: '/dashboard', label: t('dashboardNav', language), requiresAuth: true },
+ { page: 'strategy', path: '/strategy', label: t('strategyNav', language), requiresAuth: true },
+ { page: 'competition', path: '/competition', label: t('realtimeNav', language), requiresAuth: true },
+ { page: 'debate', path: '/debate', label: t('debateNav', language), requiresAuth: true },
+ { page: 'backtest', path: '/backtest', label: 'Backtest', requiresAuth: true },
+ { page: 'faq', path: '/faq', label: t('faqNav', language), requiresAuth: false },
+ ]
- {/* Language Toggle */}
-
-
-
- {t('language', language)}:
-
-
-
-
-
-
-
-
- {/* User info and logout for mobile when logged in */}
- {isLoggedIn && user && (
-
-
-
- {user.email[0].toUpperCase()}
-
-
-
- {t('loggedInAs', language)}
-
-
- {user.email}
-
-
-
- {onLogout && (
-
- )}
-
- )}
+ }
- {/* Show login/register buttons when not logged in and not on login/register pages */}
- {!isLoggedIn &&
- currentPage !== 'login' &&
- currentPage !== 'register' && (
-
-
setMobileMenuOpen(false)}
- >
- {t('signIn', language)}
-
- {registrationEnabled && (
-
setMobileMenuOpen(false)}
- >
- {t('signUp', language)}
-
+ return navTabs.map((tab, i) => (
+
handleMobileNavClick(tab)}
+ className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
+ ${currentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
+ >
+ {currentPage === tab.page && (
+
+ )}
+ {tab.label}
+ {tab.requiresAuth && !isLoggedIn && (
+
+ LOGIN_REQ
+
+ )}
+
+ ))
+ })()}
+
+ {/* Original Page Links */}
+ {isHomePage && (
+
+ {[
+ { key: 'features', label: t('features', language) },
+ { key: 'howItWorks', label: t('howItWorks', language) },
+ ].map((item, i) => (
+ setMobileMenuOpen(false)}
+ >
+ {'>'} {item.label}
+
+ ))}
+
)}
- )}
-
-
+
+ {/* Bottom Actions */}
+
+ {/* Social Links */}
+
+ {[
+ { href: OFFICIAL_LINKS.github, icon:
},
+ { href: OFFICIAL_LINKS.twitter, icon:
},
+ { href: OFFICIAL_LINKS.telegram, icon:
}
+ ].map((link, i) => (
+
+
+
+ ))}
+
+
+ {/* Account / Lang */}
+
+ {/* Lang Switcher */}
+
+ {['zh', 'en'].map((lang) => (
+
+ ))}
+
+
+ {/* Auth Actions */}
+ {isLoggedIn && user ? (
+
+ ) : (
+ currentPage !== 'login' && currentPage !== 'register' && (
+
+ {t('signIn', language)}
+
+ )
+ )}
+
+
+
+
+ )}
+
)
}
diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx
index c0364a8a..115c1def 100644
--- a/web/src/components/LoginPage.tsx
+++ b/web/src/components/LoginPage.tsx
@@ -10,13 +10,15 @@ import { useSystemConfig } from '../hooks/useSystemConfig'
export function LoginPage() {
const { language } = useLanguage()
- const { login, loginAdmin, verifyOTP } = useAuth()
- const [step, setStep] = useState<'login' | 'otp'>('login')
+ const { login, loginAdmin, verifyOTP, completeRegistration } = useAuth()
+ const [step, setStep] = useState<'login' | 'otp' | 'setup-otp'>('login')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
+ const [qrCodeURL, setQrCodeURL] = useState('') // New state for recovery
+ const [otpSecret, setOtpSecret] = useState('') // New state for recovery
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [adminPassword, setAdminPassword] = useState('')
@@ -62,9 +64,25 @@ export function LoginPage() {
const result = await login(email, password)
if (result.success) {
- if (result.requiresOTP && result.userID) {
+ // Check for incomplete OTP setup (user registered but didn't complete 2FA)
+ if (result.requiresOTPSetup && result.userID) {
setUserID(result.userID)
- setStep('otp')
+ setQrCodeURL(result.qrCodeURL || '')
+ setOtpSecret(result.otpSecret || '')
+ setStep('setup-otp')
+ toast.info("Pending 2FA setup detected. Please complete configuration.")
+ } else if (result.requiresOTP && result.userID) {
+ setUserID(result.userID)
+
+ // Check if backend provided recovery data (meaning 2FA is pending setup)
+ if (result.qrCodeURL) {
+ setQrCodeURL(result.qrCodeURL)
+ setOtpSecret(result.otpSecret || '')
+ setStep('setup-otp')
+ toast.info("Pending 2FA setup detected. Please complete configuration.")
+ } else {
+ setStep('otp')
+ }
} else {
// Dismiss the "login expired" toast on successful login (no OTP required)
if (expiredToastId) {
@@ -72,9 +90,18 @@ export function LoginPage() {
}
}
} else {
- const msg = result.message || t('loginFailed', language)
- setError(msg)
- toast.error(msg)
+ // Check if we have recovery data despite the error (e.g. "Account has not completed OTP setup")
+ if (result.qrCodeURL) {
+ setUserID(result.userID || '') // We might need to ensure userID is returned in error case too, or derived
+ setQrCodeURL(result.qrCodeURL)
+ setOtpSecret(result.otpSecret || '')
+ setStep('setup-otp')
+ toast.warning(t('completeGapSetup', language) || "Incomplete setup detected. Please configure 2FA.")
+ } else {
+ const msg = result.message || t('loginFailed', language)
+ setError(msg)
+ toast.error(msg)
+ }
}
setLoading(false)
@@ -85,7 +112,11 @@ export function LoginPage() {
setError('')
setLoading(true)
- const result = await verifyOTP(userID, otpCode)
+ // If we have qrCodeURL, it means user needs to complete registration (first time OTP setup)
+ // Otherwise, it's a normal login OTP verification
+ const result = qrCodeURL
+ ? await completeRegistration(userID, otpCode)
+ : await verifyOTP(userID, otpCode)
if (!result.success) {
const msg = result.message || t('verificationFailed', language)
@@ -96,12 +127,20 @@ export function LoginPage() {
if (expiredToastId) {
toast.dismiss(expiredToastId)
}
+ // Clear qrCodeURL after successful completion
+ setQrCodeURL('')
+ setOtpSecret('')
}
// 成功的话AuthContext会自动处理登录状态
setLoading(false)
}
+ const copyToClipboard = (text: string) => {
+ navigator.clipboard.writeText(text)
+ toast.success('Copied to clipboard')
+ }
+
return (
@@ -202,6 +241,66 @@ export function LoginPage() {
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
+ ) : step === 'setup-otp' ? (
+
+
+
COMPLETE 2FA CONFIGURATION
+ {qrCodeURL ? (
+
+
}`})
+
+ ) : (
+
+ )}
+
+
Backup Secret Key
+
+ {otpSecret}
+
+
+
+
+
+
+
+
01
+
+
Install Authenticator App
+
Recommended: Google Authenticator.
+
+ iOS
+ Android
+
+
+
+
+
+
+
+
02
+
+
Scan & Verify
+
Scan code above, then enter the 6-digit token below to activate your account.
+
+
+
+
+
+
) : step === 'login' ? (