From 8b853a963dc1b15605ecc1c0f867ce0cf4ce6cb6 Mon Sep 17 00:00:00 2001 From: Burt Date: Wed, 5 Nov 2025 21:48:28 +0800 Subject: [PATCH] Feat: Enable admin password in admin mode (#540) * WIP: save local changes before merging * Enable admin password in admin mode #374 --- .env.example | 3 + README.md | 44 +++++++ api/server.go | 142 ++++++++++++++++++----- auth/auth.go | 67 +++++++++++ docker-compose.yml | 1 + docs/getting-started/README.md | 17 +++ go.mod | 2 +- main.go | 21 +++- web/src/App.tsx | 13 ++- web/src/components/LoginPage.tsx | 95 ++++++++++++--- web/src/components/landing/HeaderBar.tsx | 42 +++---- web/src/contexts/AuthContext.tsx | 62 +++++++--- web/src/pages/LandingPage.tsx | 3 +- 13 files changed, 421 insertions(+), 91 deletions(-) diff --git a/.env.example b/.env.example index bcff8c82..2355f35a 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ NOFX_FRONTEND_PORT=3000 # Timezone Setting # System timezone for container time synchronization NOFX_TIMEZONE=Asia/Shanghai + +# Admin password when admin_mode=true +NOFX_ADMIN_PASSWORD=YOUR_PASS diff --git a/README.md b/README.md index 4a82bfed..3adfcb57 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - [🧠 AI Self-Learning](#-ai-self-learning-example) - [📊 Web Interface Features](#-web-interface-features) - [🎛️ API Endpoints](#️-api-endpoints) +- [🔐 Admin Mode (Single-User)](#-admin-mode-single-user) - [⚠️ Important Risk Warnings](#️-important-risk-warnings) - [🛠️ Common Issues](#️-common-issues) - [📈 Performance Tips](#-performance-optimization-tips) @@ -242,6 +243,49 @@ NOFX is built with a modern, modular architecture: --- +## 🔐 Admin Mode (Single-User) + +For self-hosted or single-tenant setups, NOFX supports a strict admin-only mode that disables public features and requires an admin password for all access. + +### How it works +- All API endpoints require a valid JWT when `admin_mode=true`, except: + - `GET /api/health` + - `GET /api/config` + - `POST /api/admin-login` +- Registration is gated by `allow_registration` in `config.json` (default: `true`). When `admin_mode=true`, registration is blocked regardless of this flag. +- Logout invalidates the current token via an in-memory blacklist (sufficient for single instance; use Redis for multi-instance – see Notes). + +### Quick setup +1) Set flags in `config.json`: +```jsonc +{ + // ... other config + "admin_mode": true, + "jwt_secret": "YOUR_JWT_SCR" +} +``` + +2) Provide required environment variables: +- `NOFX_ADMIN_PASSWORD` — plaintext admin password (only used at startup to derive a bcrypt hash) + +Docker Compose example (already wired): +```yaml +services: + nofx: + environment: + - NOFX_ADMIN_PASSWORD=${NOFX_ADMIN_PASSWORD} +``` + +1) Login flow (admin mode): +- Open the web UI → you’ll be redirected to the login page +- Enter admin password → the server returns a JWT +- The UI stores the token and authenticates subsequent API calls + +### Notes +- Token lifetime: 24h. On logout, tokens are blacklisted in-memory until expiry. For multi-instance deployments, use a shared store (e.g., Redis) to sync the blacklist. + +--- + ## 💰 Register Binance Account (Save on Fees!) Before using this system, you need a Binance Futures account. **Use our referral link to save on trading fees:** diff --git a/api/server.go b/api/server.go index c1767e6f..9c7a052b 100644 --- a/api/server.go +++ b/api/server.go @@ -74,35 +74,46 @@ func (s *Server) setupRoutes() { // 健康检查 api.Any("/health", s.handleHealth) - // 认证相关路由(无需认证) - api.POST("/register", s.handleRegister) - api.POST("/login", s.handleLogin) - api.POST("/verify-otp", s.handleVerifyOTP) - api.POST("/complete-registration", s.handleCompleteRegistration) - api.POST("/reset-password", s.handleResetPassword) + // 管理员登录(管理员模式下使用,公共) + api.POST("/admin-login", s.handleAdminLogin) - // 系统支持的模型和交易所(无需认证) - api.GET("/supported-models", s.handleGetSupportedModels) - api.GET("/supported-exchanges", s.handleGetSupportedExchanges) + // 非管理员模式下的公开认证路由 + if !auth.IsAdminMode() { + // 认证相关路由(无需认证) + api.POST("/register", s.handleRegister) + api.POST("/login", s.handleLogin) + api.POST("/verify-otp", s.handleVerifyOTP) + api.POST("/complete-registration", s.handleCompleteRegistration) - // 系统配置(无需认证) + // 系统支持的模型和交易所(无需认证) + api.GET("/supported-models", s.handleGetSupportedModels) + api.GET("/supported-exchanges", s.handleGetSupportedExchanges) + } + + // 系统配置(无需认证,用于前端判断是否管理员模式/注册是否开启) api.GET("/config", s.handleGetSystemConfig) - // 系统提示词模板管理(无需认证) - api.GET("/prompt-templates", s.handleGetPromptTemplates) - api.GET("/prompt-templates/:name", s.handleGetPromptTemplate) + // 系统提示词模板管理(仅在非管理员模式下公开) + if !auth.IsAdminMode() { + // 系统提示词模板管理(无需认证) + api.GET("/prompt-templates", s.handleGetPromptTemplates) + api.GET("/prompt-templates/:name", s.handleGetPromptTemplate) - // 公开的竞赛数据(无需认证) - api.GET("/traders", s.handlePublicTraderList) - api.GET("/competition", s.handlePublicCompetition) - api.GET("/top-traders", s.handleTopTraders) - api.GET("/equity-history", s.handleEquityHistory) - api.POST("/equity-history-batch", s.handleEquityHistoryBatch) - api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig) + // 公开的竞赛数据(无需认证) + api.GET("/traders", s.handlePublicTraderList) + api.GET("/competition", s.handlePublicCompetition) + api.GET("/top-traders", s.handleTopTraders) + api.GET("/equity-history", s.handleEquityHistory) + api.POST("/equity-history-batch", s.handleEquityHistoryBatch) + api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig) + } // 需要认证的路由 protected := api.Group("/", s.authMiddleware()) { + // 注销(加入黑名单) + protected.POST("/logout", s.handleLogout) + // 服务器IP查询(需要认证,用于白名单配置) protected.GET("/server-ip", s.handleGetServerIP) @@ -1460,14 +1471,6 @@ func (s *Server) handlePerformance(c *gin.Context) { // authMiddleware JWT认证中间件 func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - // 如果是管理员模式,直接使用admin用户 - if auth.IsAdminMode() { - c.Set("user_id", "admin") - c.Set("email", "admin@localhost") - c.Next() - return - } - authHeader := c.GetHeader("Authorization") if authHeader == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少Authorization头"}) @@ -1483,8 +1486,18 @@ func (s *Server) authMiddleware() gin.HandlerFunc { return } + + tokenString := tokenParts[1] + + // 黑名单检查 + if auth.IsTokenBlacklisted(tokenString) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token已失效,请重新登录"}) + c.Abort() + return + } + // 验证JWT token - claims, err := auth.ValidateJWT(tokenParts[1]) + claims, err := auth.ValidateJWT(tokenString) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token: " + err.Error()}) c.Abort() @@ -1498,8 +1511,79 @@ func (s *Server) authMiddleware() gin.HandlerFunc { } } +// handleAdminLogin 管理员登录(密码仅来自环境变量) +func (s *Server) handleAdminLogin(c *gin.Context) { + if !auth.IsAdminMode() { + c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员模式可用"}) + return + } + + // 简单的IP速率限制(5次/分钟 + 递增退避) + // 为简化,此处省略复杂实现,可在后续使用中间件或Redis增强 + + var req struct { + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Password) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少密码"}) + return + } + if !auth.CheckAdminPassword(req.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "密码错误"}) + return + } + + token, err := auth.GenerateJWT("admin", "admin@localhost") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"token": token, "user_id": "admin", "email": "admin@localhost"}) +} + +// handleLogout 将当前token加入黑名单 +func (s *Server) handleLogout(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少Authorization头"}) + return + } + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的Authorization格式"}) + return + } + tokenString := parts[1] + claims, err := auth.ValidateJWT(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"}) + return + } + var exp time.Time + if claims.ExpiresAt != nil { + exp = claims.ExpiresAt.Time + } else { + exp = time.Now().Add(24 * time.Hour) + } + auth.BlacklistToken(tokenString, exp) + c.JSON(http.StatusOK, gin.H{"message": "已登出"}) +} + // handleRegister 处理用户注册请求 func (s *Server) handleRegister(c *gin.Context) { + // 管理员模式下禁用注册 + if auth.IsAdminMode() { + c.JSON(http.StatusForbidden, gin.H{"error": "管理员模式下禁用注册"}) + return + } + + // 若未开启注册,返回403 + allowRegStr, _ := s.database.GetSystemConfig("allow_registration") + if allowRegStr == "false" { + c.JSON(http.StatusForbidden, gin.H{"error": "注册已关闭"}) + return + } + var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` diff --git a/auth/auth.go b/auth/auth.go index 89c58e5c..ca23a4b9 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -3,6 +3,8 @@ package auth import ( "crypto/rand" "fmt" + "log" + "sync" "time" "github.com/golang-jwt/jwt/v5" @@ -17,6 +19,18 @@ var JWTSecret []byte // AdminMode 管理员模式标志 var AdminMode bool = false +// adminPasswordHash 管理员密码哈希(仅内存) +var adminPasswordHash string + +// tokenBlacklist 用于登出后的token黑名单(仅内存,按过期时间清理) +var tokenBlacklist = struct { + sync.RWMutex + items map[string]time.Time +}{items: make(map[string]time.Time)} + +// maxBlacklistEntries 黑名单最大容量阈值 +const maxBlacklistEntries = 100_000 + // OTPIssuer OTP发行者名称 const OTPIssuer = "nofxAI" @@ -35,6 +49,59 @@ func IsAdminMode() bool { return AdminMode } +// SetAdminPasswordFromPlain 通过明文设置管理员密码(会使用bcrypt哈希,成本12) +func SetAdminPasswordFromPlain(plain string) error { + bytes, err := bcrypt.GenerateFromPassword([]byte(plain), 12) + if err != nil { + return err + } + adminPasswordHash = string(bytes) + return nil +} + +// CheckAdminPassword 校验管理员密码 +func CheckAdminPassword(plain string) bool { + if adminPasswordHash == "" { + return false + } + return bcrypt.CompareHashAndPassword([]byte(adminPasswordHash), []byte(plain)) == nil +} + +// BlacklistToken 将token加入黑名单直到过期 +func BlacklistToken(token string, exp time.Time) { + tokenBlacklist.Lock() + defer tokenBlacklist.Unlock() + tokenBlacklist.items[token] = exp + + // 如果超过容量阈值,则进行一次过期清理;若仍超限,记录警告日志 + if len(tokenBlacklist.items) > maxBlacklistEntries { + now := time.Now() + for t, e := range tokenBlacklist.items { + if now.After(e) { + delete(tokenBlacklist.items, t) + } + } + if len(tokenBlacklist.items) > maxBlacklistEntries { + log.Printf("auth: token blacklist size (%d) exceeds limit (%d) after sweep; consider reducing JWT TTL or using a shared persistent store", + len(tokenBlacklist.items), maxBlacklistEntries) + } + } +} + +// IsTokenBlacklisted 检查token是否在黑名单中(过期自动清理) +func IsTokenBlacklisted(token string) bool { + tokenBlacklist.Lock() + defer tokenBlacklist.Unlock() + if exp, ok := tokenBlacklist.items[token]; ok { + if time.Now().After(exp) { + delete(tokenBlacklist.items, token) + return false + } + return true + } + return false +} + // Claims JWT声明 type Claims struct { UserID string `json:"user_id"` diff --git a/docker-compose.yml b/docker-compose.yml index dc25bb44..075b6754 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: environment: - TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone - AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000) + - NOFX_ADMIN_PASSWORD=${NOFX_ADMIN_PASSWORD} # Admin password when admin_mode=true networks: - nofx-network healthcheck: diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 9e1740f7..41339cd7 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -90,6 +90,23 @@ After deployment: 3. **Create Traders** → Combine AI models with exchanges 4. **Start Trading** → Monitor performance in dashboard +### 🔐 Optional: Enable Admin Mode (Single-User) + +For single-tenant/self-hosted usage, you can enable strict admin-only access: + +1) In `config.json` set the 2 fields below: +```jsonc +{ + "admin_mode": true, + ... + "jwt_secret": "YOUR_JWT_SCR" +} +``` +2) Set environment variables (Docker compose already wired): +- `NOFX_ADMIN_PASSWORD` — admin password (plaintext; hashed on startup) + +3) Login at `/login` using the admin password. All non-essential endpoints are blocked to unauthenticated users while admin mode is enabled. + --- ## ⚠️ Important Notes diff --git a/go.mod b/go.mod index b92a7d38..d48551d1 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 github.com/pquerna/otp v1.4.0 github.com/sirupsen/logrus v1.9.3 github.com/sonirico/go-hyperliquid v0.17.0 @@ -42,7 +43,6 @@ require ( github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/holiman/uint256 v1.3.2 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/main.go b/main.go index 873f4a80..3a3f1e68 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,8 @@ import ( "strconv" "strings" "syscall" + + "github.com/joho/godotenv" ) // LeverageConfig 杠杆配置 @@ -160,6 +162,10 @@ func main() { fmt.Println("╚════════════════════════════════════════════════════════════╝") fmt.Println() + // Load environment variables from .env file if present (for local/dev runs) + // In Docker Compose, variables are injected by the runtime and this is harmless. + _ = godotenv.Load() + // 初始化数据库配置 dbPath := "config.db" if len(os.Args) > 1 { @@ -206,17 +212,20 @@ func main() { } auth.SetJWTSecret(jwtSecret) - // 在管理员模式下,确保admin用户存在 + // 管理员模式下需要管理员密码,缺失则退出 if adminMode { - err := database.EnsureAdminUser() - if err != nil { - log.Printf("⚠️ 创建admin用户失败: %v", err) - } else { - log.Printf("✓ 管理员模式已启用,无需登录") + adminPassword := os.Getenv("NOFX_ADMIN_PASSWORD") + if adminPassword == "" { + log.Fatalf("Admin mode is enabled but NOFX_ADMIN_PASSWORD is missing. Set NOFX_ADMIN_PASSWORD and restart.") + } + if err := auth.SetAdminPasswordFromPlain(adminPassword); err != nil { + log.Fatalf("Failed to set admin password: %v", err) } auth.SetAdminMode(true) + log.Printf("✓ Admin mode enabled. All API endpoints require admin authentication.") } + log.Printf("✓ 配置数据库初始化成功") fmt.Println() diff --git a/web/src/App.tsx b/web/src/App.tsx index 147eca2f..92b2e65c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -229,6 +229,10 @@ function App() { return } if (route === '/register') { + if (systemConfig?.admin_mode) { + window.history.pushState({}, '', '/login'); + return ; + } return } if (route === '/reset-password') { @@ -286,10 +290,15 @@ function App() { // Show landing page for root route if (route === '/' || route === '') { - return + return ; } - // Show main app for authenticated users on other routes + // In admin mode, require authentication for any protected routes + if (systemConfig?.admin_mode && (!user || !token)) { + return ; + } + + // Show main app for authenticated users on other routes (non-admin mode) if (!systemConfig?.admin_mode && (!user || !token)) { // Default to landing page when not authenticated and no specific route return diff --git a/web/src/components/LoginPage.tsx b/web/src/components/LoginPage.tsx index 0e07e1bc..a15efd83 100644 --- a/web/src/components/LoginPage.tsx +++ b/web/src/components/LoginPage.tsx @@ -1,12 +1,13 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react'; import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import HeaderBar from './landing/HeaderBar' +import { getSystemConfig } from '../lib/config'; export function LoginPage() { const { language } = useLanguage() - const { login, verifyOTP } = useAuth() + const { login, loginAdmin, verifyOTP } = useAuth() const [step, setStep] = useState<'login' | 'otp'>('login') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -14,6 +15,30 @@ export function LoginPage() { const [userID, setUserID] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [adminPassword, setAdminPassword] = useState(''); + const [adminMode, setAdminMode] = useState(null); + + useEffect(() => { + getSystemConfig() + .then((cfg) => { + setAdminMode(!!cfg.admin_mode); + }) + .catch(() => { + setAdminMode(false); + }); + }, []); + + const handleAdminLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + const result = await loginAdmin(adminPassword); + if (!result.success) { + setError(result.message || t('loginFailed', language)); + } + setLoading(false); + }; + const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -102,7 +127,39 @@ export function LoginPage() { border: '1px solid var(--panel-border)', }} > - {step === 'login' ? ( + {adminMode ? ( +
+
+ + setAdminPassword(e.target.value)} + className="w-full px-3 py-2 rounded" + style={{ background: 'var(--brand-black)', border: '1px solid var(--panel-border)', color: 'var(--brand-light-gray)' }} + placeholder="请输入管理员密码" + required + /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ ) : step === 'login' ? (
diff --git a/web/src/components/landing/HeaderBar.tsx b/web/src/components/landing/HeaderBar.tsx index 37f2ae8b..995205c8 100644 --- a/web/src/components/landing/HeaderBar.tsx +++ b/web/src/components/landing/HeaderBar.tsx @@ -394,16 +394,16 @@ export default function HeaderBar({ > {t('signIn', language)} - + {!isAdminMode && ( + {t('signUp', language)} - + + )} + ) )} @@ -797,17 +797,19 @@ export default function HeaderBar({ > {t('signIn', language)} - setMobileMenuOpen(false)} - > - {t('signUp', language)} - + {!isAdminMode && ( + setMobileMenuOpen(false)} + > + {t('signUp', language)} + + )} )} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 5e87d331..d0fcc9aa 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -18,6 +18,12 @@ interface AuthContextType { userID?: string requiresOTP?: boolean }> + loginAdmin: ( + password: string + ) => Promise<{ + success: boolean; + message?: string + }>; register: ( email: string, password: string, @@ -56,21 +62,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { useEffect(() => { // 先检查是否为管理员模式(使用带缓存的系统配置获取) getSystemConfig() - .then((data) => { - if (data.admin_mode) { - // 管理员模式下,模拟admin用户 - setUser({ id: 'admin', email: 'admin@localhost' }) - setToken('admin-mode') - } else { - // 非管理员模式,检查本地存储中是否有token - const savedToken = localStorage.getItem('auth_token') - const savedUser = localStorage.getItem('auth_user') - - if (savedToken && savedUser) { - setToken(savedToken) - setUser(JSON.parse(savedUser)) - } + .then(() => { + // 不再在管理员模式下模拟登录;统一检查本地存储 + const savedToken = localStorage.getItem('auth_token'); + const savedUser = localStorage.getItem('auth_user'); + if (savedToken && savedUser) { + setToken(savedToken); + setUser(JSON.parse(savedUser)); } + setIsLoading(false) }) .catch((err) => { @@ -118,6 +118,32 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { success: false, message: '未知错误' } } + const loginAdmin = async (password: string) => { + try { + const response = await fetch('/api/admin-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + if (response.ok) { + const userInfo = { id: data.user_id || 'admin', email: data.email || 'admin@localhost' }; + setToken(data.token); + setUser(userInfo); + localStorage.setItem('auth_token', data.token); + localStorage.setItem('auth_user', JSON.stringify(userInfo)); + // 跳转到仪表盘 + window.history.pushState({}, '', '/dashboard'); + window.dispatchEvent(new PopStateEvent('popstate')); + return { success: true }; + } else { + return { success: false, message: data.error || '登录失败' }; + } + } catch (e) { + return { success: false, message: '登录失败,请重试' }; + } + }; + const register = async ( email: string, password: string, @@ -256,6 +282,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const logout = () => { + const savedToken = localStorage.getItem('auth_token'); + if (savedToken) { + fetch('/api/logout', { + method: 'POST', + headers: { 'Authorization': `Bearer ${savedToken}` }, + }).catch(() => {/* ignore network errors on logout */}); + } setUser(null) setToken(null) localStorage.removeItem('auth_token') @@ -268,6 +301,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { user, token, login, + loginAdmin, register, verifyOTP, completeRegistration, diff --git a/web/src/pages/LandingPage.tsx b/web/src/pages/LandingPage.tsx index 4135ee60..dfeb7b1d 100644 --- a/web/src/pages/LandingPage.tsx +++ b/web/src/pages/LandingPage.tsx @@ -14,7 +14,7 @@ import { useAuth } from '../contexts/AuthContext' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' -export function LandingPage() { +export function LandingPage({ isAdminMode = false }: { isAdminMode?: boolean }) { const [showLoginModal, setShowLoginModal] = useState(false) const { user, logout } = useAuth() const { language, setLanguage } = useLanguage() @@ -31,6 +31,7 @@ export function LandingPage() { onLanguageChange={setLanguage} user={user} onLogout={logout} + isAdminMode={isAdminMode} onPageChange={(page) => { console.log('LandingPage onPageChange called with:', page) if (page === 'competition') {