feat: add whether to enable self registration toggle (#760)

* refactor(frontend): extract RegistrationDisabled as reusable component
- Create RegistrationDisabled component with i18n support
- Add registrationClosed and registrationClosedMessage translations
- Replace inline JSX in App.tsx with new component
- Improve code maintainability and reusability
- Add hover effect to back button for better UX
* fix(frontend): add registration toggle to LoginModal component
- Add useSystemConfig hook to LoginModal
- Conditionally render registration button based on registration_enabled config
- Ensures consistency with HeaderBar and LoginPage registration controls
- Completes registration toggle feature implementation across all entry points
* feat(frontend): add registration toggle UI support
- Add registration disabled page in App.tsx when registration is closed
- Hide registration link in LoginPage when registration is disabled
- Add registration_enabled field to SystemConfig interface
- Frontend conditionally shows/hides registration UI based on backend config
* feat: add registration toggle feature
Add system-level registration enable/disable control:
- Add registration_enabled config to system_config table (default: true)
- Add registration check in handleRegister API endpoint
- Expose registration_enabled status in /api/config endpoint
- Frontend can use this config to conditionally show/hide registration UI
This allows administrators to control user registration without code changes.
* fix(frontend): add registration toggle to HeaderBar and RegisterPage
- Add useSystemConfig hook and registrationEnabled check to HeaderBar
- Conditionally show/hide signup buttons in both desktop and mobile views
- Add registration check to RegisterPage to show RegistrationDisabled component
- This completes the registration toggle feature across all UI components
* test(frontend): add comprehensive unit tests for registration toggle feature
- Add RegistrationDisabled component tests (rendering, navigation, styling)
- Add registrationToggle logic tests (config handling, edge cases, multi-location consistency)
- Configure Vitest with jsdom environment for React component testing
- All 80 tests passing (9 new tests for RegistrationDisabled + 21 for toggle logic)
This commit is contained in:
Lawrence Liu
2025-11-13 01:37:24 +08:00
committed by tangmengqiu
parent b282045b66
commit ced6c3d9de
13 changed files with 437 additions and 750 deletions

View File

@@ -4,6 +4,7 @@ import { motion } from 'framer-motion'
import { Menu, X, ChevronDown } from 'lucide-react'
import { t, type Language } from '../i18n/translations'
import { Container } from './Container'
import { useSystemConfig } from '../hooks/useSystemConfig'
interface HeaderBarProps {
onLoginClick?: () => void
@@ -33,6 +34,8 @@ export default function HeaderBar({
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const userDropdownRef = useRef<HTMLDivElement>(null)
const { config: systemConfig } = useSystemConfig()
const registrationEnabled = systemConfig?.registration_enabled !== false
// Close dropdown when clicking outside
useEffect(() => {
@@ -464,16 +467,18 @@ export default function HeaderBar({
>
{t('signIn', language)}
</a>
<a
href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('signUp', language)}
</a>
{registrationEnabled && (
<a
href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
>
{t('signUp', language)}
</a>
)}
</div>
)
)}
@@ -901,17 +906,19 @@ export default function HeaderBar({
>
{t('signIn', language)}
</a>
<a
href="/register"
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
{registrationEnabled && (
<a
href="/register"
className="block w-full px-4 py-2 rounded font-semibold text-sm text-center transition-colors"
style={{
background: 'var(--brand-yellow)',
color: 'var(--brand-black)',
}}
onClick={() => setMobileMenuOpen(false)}
>
{t('signUp', language)}
</a>
)}
</div>
)}
</div>

View File

@@ -6,6 +6,7 @@ import { t } from '../i18n/translations'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from './ui/input'
import { toast } from 'sonner'
import { useSystemConfig } from '../hooks/useSystemConfig'
export function LoginPage() {
const { language } = useLanguage()
@@ -21,6 +22,8 @@ export function LoginPage() {
const [loading, setLoading] = useState(false)
const [adminPassword, setAdminPassword] = useState('')
const adminMode = false
const { config: systemConfig } = useSystemConfig()
const registrationEnabled = systemConfig?.registration_enabled !== false
const handleAdminLogin = async (e: React.FormEvent) => {
e.preventDefault()
@@ -313,7 +316,7 @@ export function LoginPage() {
</div>
{/* Register Link */}
{!adminMode && (
{!adminMode && registrationEnabled && (
<div className="text-center mt-6">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{' '}

View File

@@ -9,6 +9,7 @@ import { copyWithToast } from '../lib/clipboard'
import { Eye, EyeOff } from 'lucide-react'
import { Input } from './ui/input'
import PasswordChecklist from 'react-password-checklist'
import { RegistrationDisabled } from './RegistrationDisabled'
export function RegisterPage() {
const { language } = useLanguage()
@@ -22,6 +23,7 @@ export function RegisterPage() {
const [confirmPassword, setConfirmPassword] = useState('')
const [betaCode, setBetaCode] = useState('')
const [betaMode, setBetaMode] = useState(false)
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [otpCode, setOtpCode] = useState('')
const [userID, setUserID] = useState('')
const [otpSecret, setOtpSecret] = useState('')
@@ -33,16 +35,22 @@ export function RegisterPage() {
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
// 获取系统配置,检查是否开启内测模式
// 获取系统配置,检查是否开启内测模式和注册功能
getSystemConfig()
.then((config) => {
setBetaMode(config.beta_mode || false)
setRegistrationEnabled(config.registration_enabled !== false)
})
.catch((err) => {
console.error('Failed to fetch system config:', err)
})
}, [])
// 如果注册功能被禁用,显示注册已关闭页面
if (!registrationEnabled) {
return <RegistrationDisabled />
}
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError('')

View 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')
})
})
})

View 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>
)
}

View File

@@ -1,6 +1,7 @@
import { motion } from 'framer-motion'
import { X } from 'lucide-react'
import { t, Language } from '../../i18n/translations'
import { useSystemConfig } from '../../hooks/useSystemConfig'
interface LoginModalProps {
onClose: () => void
@@ -8,6 +9,9 @@ interface LoginModalProps {
}
export default function LoginModal({ onClose, language }: LoginModalProps) {
const { config: systemConfig } = useSystemConfig()
const registrationEnabled = systemConfig?.registration_enabled !== false
return (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
@@ -66,23 +70,25 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
>
{t('signIn', language)}
</motion.button>
<motion.button
onClick={() => {
window.history.pushState({}, '', '/register')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-dark-gray)',
color: 'var(--brand-light-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }}
>
{t('registerNewAccount', language)}
</motion.button>
{registrationEnabled && (
<motion.button
onClick={() => {
window.history.pushState({}, '', '/register')
window.dispatchEvent(new PopStateEvent('popstate'))
onClose()
}}
className="block w-full px-6 py-3 rounded-lg font-semibold text-center"
style={{
background: 'var(--brand-dark-gray)',
color: 'var(--brand-light-gray)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
whileHover={{ scale: 1.05, borderColor: 'var(--brand-yellow)' }}
whileTap={{ scale: 0.95 }}
>
{t('registerNewAccount', language)}
</motion.button>
)}
</div>
</motion.div>
</motion.div>

View File

@@ -496,6 +496,9 @@ export const translations = {
exitLogin: 'Sign Out',
signIn: 'Sign In',
signUp: 'Sign Up',
registrationClosed: 'Registration Closed',
registrationClosedMessage:
'User registration is currently disabled. Please contact the administrator for access.',
// Hero Section
githubStarsInDays: '2.5K+ GitHub Stars in 3 days',
@@ -1305,6 +1308,8 @@ export const translations = {
exitLogin: '退出登录',
signIn: '登录',
signUp: '注册',
registrationClosed: '注册已关闭',
registrationClosedMessage: '平台当前不开放新用户注册,如需访问请联系管理员获取账号。',
// Hero Section
githubStarsInDays: '3 天内 2.5K+ GitHub Stars',

View File

@@ -1,5 +1,6 @@
export interface SystemConfig {
beta_mode: boolean
registration_enabled?: boolean
}
let configPromise: Promise<SystemConfig> | null = null

View File

@@ -0,0 +1,204 @@
import { describe, it, expect } from 'vitest'
/**
* Registration Toggle Feature Tests
*
* Tests the logic for determining whether registration is enabled
* This validates the registration_enabled configuration behavior
*/
describe('Registration Toggle Logic', () => {
describe('registration_enabled configuration', () => {
it('should default to true when registration_enabled is undefined', () => {
const config = {}
const registrationEnabled = (config as any).registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
it('should be true when registration_enabled is explicitly true', () => {
const config = { registration_enabled: true }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
it('should be false when registration_enabled is explicitly false', () => {
const config = { registration_enabled: false }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(false)
})
it('should default to true when registration_enabled is null', () => {
const config = { registration_enabled: null }
const registrationEnabled = (config.registration_enabled as any) !== false
expect(registrationEnabled).toBe(true)
})
it('should handle missing config gracefully', () => {
const config = null
const registrationEnabled = config?.registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
})
describe('UI component visibility logic', () => {
it('should show signup button when registration is enabled', () => {
const registrationEnabled = true
const shouldShowSignup = registrationEnabled
expect(shouldShowSignup).toBe(true)
})
it('should hide signup button when registration is disabled', () => {
const registrationEnabled = false
const shouldShowSignup = registrationEnabled
expect(shouldShowSignup).toBe(false)
})
})
describe('conditional rendering patterns', () => {
it('should render signup link with registrationEnabled && pattern', () => {
const registrationEnabled = true
const signupElement = registrationEnabled && 'SignUpButton'
expect(signupElement).toBe('SignUpButton')
})
it('should not render signup link when disabled', () => {
const registrationEnabled = false
const signupElement = registrationEnabled && 'SignUpButton'
expect(signupElement).toBe(false)
})
})
describe('SystemConfig interface compliance', () => {
interface SystemConfig {
beta_mode: boolean
registration_enabled?: boolean
}
it('should have optional registration_enabled field', () => {
const config1: SystemConfig = {
beta_mode: false,
}
const config2: SystemConfig = {
beta_mode: false,
registration_enabled: true,
}
expect(config1.beta_mode).toBe(false)
expect(config2.registration_enabled).toBe(true)
})
it('should handle both beta_mode and registration_enabled', () => {
const config: SystemConfig = {
beta_mode: true,
registration_enabled: false,
}
expect(config.beta_mode).toBe(true)
expect(config.registration_enabled).toBe(false)
})
})
describe('edge cases', () => {
it('should treat empty string as truthy (not false)', () => {
const config = { registration_enabled: '' as any }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
it('should treat 0 as truthy (not false)', () => {
const config = { registration_enabled: 0 as any }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
it('should treat "false" string as truthy (not false)', () => {
const config = { registration_enabled: 'false' as any }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
it('should only treat boolean false as disabled', () => {
const testCases = [
{ value: false, expected: false },
{ value: true, expected: true },
{ value: null, expected: true },
{ value: undefined, expected: true },
{ value: 0, expected: true },
{ value: '', expected: true },
{ value: 'false', expected: true },
{ value: [], expected: true },
{ value: {}, expected: true },
]
testCases.forEach(({ value, expected }) => {
const config = { registration_enabled: value as any }
const registrationEnabled = config.registration_enabled !== false
expect(registrationEnabled).toBe(expected)
})
})
})
describe('backend API response handling', () => {
it('should parse backend response with registration_enabled', () => {
const apiResponse = {
beta_mode: false,
default_coins: ['BTCUSDT'],
btc_eth_leverage: 5,
altcoin_leverage: 5,
registration_enabled: true,
}
expect(apiResponse.registration_enabled).toBe(true)
})
it('should handle backend response without registration_enabled', () => {
const apiResponse = {
beta_mode: false,
default_coins: ['BTCUSDT'],
btc_eth_leverage: 5,
altcoin_leverage: 5,
}
const registrationEnabled =
(apiResponse as any).registration_enabled !== false
expect(registrationEnabled).toBe(true)
})
})
describe('multi-location consistency', () => {
const systemConfig = { registration_enabled: false }
it('should have consistent behavior across LoginPage', () => {
const registrationEnabled = systemConfig?.registration_enabled !== false
expect(registrationEnabled).toBe(false)
})
it('should have consistent behavior across RegisterPage', () => {
const registrationEnabled = systemConfig?.registration_enabled !== false
expect(registrationEnabled).toBe(false)
})
it('should have consistent behavior across HeaderBar', () => {
const registrationEnabled = systemConfig?.registration_enabled !== false
expect(registrationEnabled).toBe(false)
})
it('should have consistent behavior across LoginModal', () => {
const registrationEnabled = systemConfig?.registration_enabled !== false
expect(registrationEnabled).toBe(false)
})
})
})