mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-03 11:00:58 +08:00
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:
committed by
tangmengqiu
parent
b282045b66
commit
ced6c3d9de
@@ -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>
|
||||
|
||||
@@ -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)' }}>
|
||||
还没有账户?{' '}
|
||||
|
||||
@@ -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('')
|
||||
|
||||
103
web/src/components/RegistrationDisabled.test.tsx
Normal file
103
web/src/components/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/RegistrationDisabled.tsx
Normal file
39
web/src/components/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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user