refactor: single-user web-based setup — replace env config with Settings UI

Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup
This commit is contained in:
tinkle-community
2026-03-09 23:55:39 +08:00
parent 9a3017af6d
commit 3ed0aec0ff
18 changed files with 1044 additions and 482 deletions

View File

@@ -6,7 +6,8 @@ import { TraderDashboardPage } from './pages/TraderDashboardPage'
import { AITradersPage } from './components/AITradersPage'
import { LoginPage } from './components/LoginPage'
import { RegisterPage } from './components/RegisterPage'
import { SetupPage } from './components/SetupPage'
import { SettingsPage } from './pages/SettingsPage'
import { ResetPasswordPage } from './components/ResetPasswordPage'
import { CompetitionPage } from './components/CompetitionPage'
import { LandingPage } from './pages/LandingPage'
@@ -53,7 +54,7 @@ type Page =
function App() {
const { language, setLanguage } = useLanguage()
const { user, token, logout, isLoading } = useAuth()
const { loading: configLoading } = useSystemConfig()
const { config: systemConfig, loading: configLoading } = useSystemConfig()
const [route, setRoute] = useState(window.location.pathname)
// Debug log
@@ -341,12 +342,22 @@ function App() {
)
}
// First-time setup: redirect to /setup if system not initialized
if (systemConfig && !systemConfig.initialized && !user) {
return <SetupPage />
}
// Handle specific routes regardless of authentication
if (route === '/login') {
return <LoginPage />
}
if (route === '/register') {
return <RegisterPage />
if (route === '/setup') {
// If already initialized, redirect to login
if (systemConfig?.initialized) {
window.location.href = '/login'
return null
}
return <SetupPage />
}
if (route === '/faq') {
return (
@@ -376,6 +387,26 @@ function App() {
if (route === '/reset-password') {
return <ResetPasswordPage />
}
if (route === '/settings') {
if (!user || !token) {
window.location.href = '/login'
return null
}
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
<HeaderBar
isLoggedIn={!!user}
language={language}
onLanguageChange={setLanguage}
user={user}
onLogout={logout}
onLoginRequired={handleLoginRequired}
onPageChange={navigateToPage}
/>
<SettingsPage />
</div>
)
}
// Data page - publicly accessible with embedded dashboard
if (route === '/data') {
const dataPageNavigate = (page: Page) => {

View File

@@ -1499,7 +1499,7 @@ function ModelCard({
}
// Model Configuration Modal Component
function ModelConfigModal({
export function ModelConfigModal({
allModels,
configuredModels,
editingModelId,

View File

@@ -1,9 +1,8 @@
import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X, ChevronDown } from 'lucide-react'
import { Menu, X, ChevronDown, Settings } from 'lucide-react'
import { t, type Language } from '../i18n/translations'
import { useSystemConfig } from '../hooks/useSystemConfig'
import { OFFICIAL_LINKS } from '../constants/branding'
type Page =
@@ -49,9 +48,6 @@ 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(() => {
function handleClickOutside(event: MouseEvent) {
@@ -214,6 +210,16 @@ export default function HeaderBar({
{user.email}
</div>
</div>
<button
onClick={() => {
window.location.href = '/settings'
setUserDropdownOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
Settings
</button>
{onLogout && (
<button
onClick={() => {
@@ -240,14 +246,6 @@ export default function HeaderBar({
>
{t('signIn', language)}
</a>
{registrationEnabled && (
<a
href="/register"
className="px-4 py-2 rounded font-semibold text-sm transition-colors hover:opacity-90 bg-nofx-gold text-black"
>
{t('signUp', language)}
</a>
)}
</div>
)
)}

View File

@@ -1,277 +1,133 @@
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 { Eye, EyeOff } from 'lucide-react'
import { DeepVoidBackground } from './DeepVoidBackground'
// import { Input } from './ui/input' // Removed unused import
import { toast } from 'sonner'
import { useSystemConfig } from '../hooks/useSystemConfig'
export function LoginPage() {
const { language } = useLanguage()
const { login, loginAdmin } = useAuth()
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 [adminPassword, setAdminPassword] = useState('')
const adminMode = false
const { config: systemConfig } = useSystemConfig()
const registrationEnabled = systemConfig?.registration_enabled !== false
const [expiredToastId, setExpiredToastId] = useState<string | number | null>(null)
// Show notification if user was redirected here due to 401
useEffect(() => {
if (sessionStorage.getItem('from401') === 'true') {
const id = toast.warning(t('sessionExpired', language), {
duration: Infinity // Keep showing until user dismisses or logs in
})
const id = toast.warning(t('sessionExpired', language), { duration: Infinity })
setExpiredToastId(id)
sessionStorage.removeItem('from401')
}
}, [language])
const handleAdminLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await loginAdmin(adminPassword)
if (!result.success) {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
} else {
// Dismiss the "login expired" toast on successful login
if (expiredToastId) {
toast.dismiss(expiredToastId)
}
}
setLoading(false)
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
const result = await login(email, password)
setLoading(false)
if (result.success) {
// Dismiss the "login expired" toast on successful login.
if (expiredToastId) {
toast.dismiss(expiredToastId)
}
if (expiredToastId) toast.dismiss(expiredToastId)
} else {
const msg = result.message || t('loginFailed', language)
setError(msg)
toast.error(msg)
}
setLoading(false)
}
return (
<DeepVoidBackground className="min-h-screen flex items-center justify-center py-12 font-mono" disableAnimation>
<DeepVoidBackground disableAnimation>
<div className="flex-1 flex items-center justify-center px-4 py-16">
<div className="w-full max-w-sm">
<div className="w-full max-w-md relative z-10 px-6">
{/* Navigation - Top Bar (Mobile/Desktop Friendly) */}
<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">&lt; CANCEL_LOGIN</span>
</button>
</div>
{/* Terminal Header */}
<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">SYSTEM</span> ACCESS
</h1>
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
Authentication Protocol v3.0
</p>
</div>
{/* Terminal Output / Form Container */}
<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>
{/* Window Bar */}
<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> login.exe
{/* 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>
<div className="p-6 md:p-8 relative">
{/* Status Output */}
<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>Initiating handshake...</span>
</div>
<div className="flex gap-2">
<span className="text-emerald-500"></span>
<span>Target: NOFX CORE HUB</span>
</div>
<div className="flex gap-2">
<span className="text-emerald-500"></span>
<span>Status: <span className="text-zinc-300">AWAITING CREDENTIALS</span></span>
</div>
</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">
{adminMode ? (
<form onSubmit={handleAdminLogin} className="space-y-5">
<div>
<label className="block text-xs uppercase tracking-wider text-nofx-gold mb-1.5 ml-1">Admin Key</label>
{/* 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="password"
value={adminPassword}
onChange={(e) => setAdminPassword(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-700 text-white font-mono"
placeholder="ENTER_ROOT_PASSWORD"
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 && (
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
[ERROR]: {error}
</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>
)}
<button
type="submit"
disabled={loading}
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-95 disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_20px_rgba(255,215,0,0.1)] hover:shadow-[0_0_30px_rgba(255,215,0,0.3)]"
>
{loading ? '> VERIFYING...' : '> EXECUTE_LOGIN'}
</button>
</form>
) : (
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-4">
<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-700 text-white font-mono"
placeholder="user@nofx.os"
required
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5 ml-1">
<label className="block text-xs uppercase tracking-wider text-zinc-500 font-bold">{t('password', language)}</label>
</div>
<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-700 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 className="text-right mt-2">
<button
type="button"
onClick={() => window.location.href = '/reset-password'}
className="text-[10px] uppercase tracking-wide text-zinc-500 hover:text-nofx-gold transition-colors"
>
&gt; {t('forgotPassword', language)}
</button>
</div>
</div>
</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 flex gap-2 items-start">
<span></span> <span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
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"
>
{loading ? (
<span className="animate-pulse">PROCESSING...</span>
) : (
<>
<span>AUTHENTICATE</span>
<span className="group-hover:translate-x-1 transition-transform">-&gt;</span>
</>
)}
</button>
</form>
)}
</div>
{/* Terminal Footer Info */}
<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>SECURE_CONNECTION: ENCRYPTED</div>
<div>{new Date().toISOString().split('T')[0]}</div>
</div>
</div>
{/* Register Link */}
{!adminMode && registrationEnabled && (
<div className="text-center mt-8 space-y-4">
<p className="text-xs font-mono text-zinc-500">
NEW_USER_DETECTED?{' '}
{/* Submit */}
<button
onClick={() => window.location.href = '/register'}
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
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"
>
INITIALIZE REGISTRATION
{loading ? t('loggingIn', language) || 'Signing in...' : t('signIn', language) || 'Sign In'}
</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_SESSION_RETURN_HOME ]
</button>
</form>
</div>
)}
</div>
</div>
</DeepVoidBackground>
)

View File

@@ -30,7 +30,7 @@ export function RegisterPage() {
getSystemConfig()
.then((config) => {
setBetaMode(config.beta_mode || false)
setRegistrationEnabled(config.registration_enabled !== false)
setRegistrationEnabled(config.initialized === false)
})
.catch((err) => {
console.error('Failed to fetch system config:', err)

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { DeepVoidBackground } from './DeepVoidBackground'
import { invalidateSystemConfig } from '../lib/config'
export function SetupPage() {
const { register } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
const result = await register(email, password)
setLoading(false)
if (result.success) {
invalidateSystemConfig()
window.location.href = '/traders'
} else {
setError(result.message || 'Setup failed, please try again')
}
}
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 to NOFX</h1>
<p className="text-zinc-500 text-sm">Create your account to get started</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={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">Email</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>
<label className="block text-xs font-medium text-zinc-400 mb-2">Password</label>
<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="At least 8 characters"
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 ? 'Creating account...' : 'Get Started'}
</button>
</form>
</div>
<p className="text-center text-xs text-zinc-600 mt-6">
Single-user system &mdash; this is the only account
</p>
</div>
</div>
</DeepVoidBackground>
)
}

View File

@@ -1,16 +1,12 @@
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
language: Language
}
export default function LoginModal({ onClose, language }: LoginModalProps) {
const { config: systemConfig } = useSystemConfig()
const registrationEnabled = systemConfig?.registration_enabled !== false
return (
<motion.div
@@ -70,25 +66,6 @@ export default function LoginModal({ onClose, language }: LoginModalProps) {
>
{t('signIn', 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

@@ -1,6 +1,6 @@
export interface SystemConfig {
beta_mode: boolean
registration_enabled?: boolean
initialized: boolean
beta_mode?: boolean
}
let configPromise: Promise<SystemConfig> | null = null
@@ -19,8 +19,11 @@ export function getSystemConfig(): Promise<SystemConfig> {
cachedConfig = data
return data
})
.finally(() => {
// Keep cachedConfig for reuse; allow re-fetch via explicit invalidation if added later
})
return configPromise
}
/** Call after first-time setup completes so next check reflects initialized=true */
export function invalidateSystemConfig() {
cachedConfig = null
configPromise = null
}

View File

@@ -0,0 +1,489 @@
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { User, Cpu, Building2, MessageCircle, Eye, EyeOff, ChevronRight, Plus, Pencil } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import { api } from '../lib/api'
import { ExchangeConfigModal } from '../components/traders/ExchangeConfigModal'
import { TelegramConfigModal } from '../components/traders/TelegramConfigModal'
import { ModelConfigModal } from '../components/AITradersPage'
import type { Exchange, AIModel } from '../types'
type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
export function SettingsPage() {
const { user } = useAuth()
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState<Tab>('account')
// Account state
const [newPassword, setNewPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [changingPassword, setChangingPassword] = useState(false)
// AI Models state
const [configuredModels, setConfiguredModels] = useState<AIModel[]>([])
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [showModelModal, setShowModelModal] = useState(false)
const [editingModel, setEditingModel] = useState<string | null>(null)
// Exchanges state
const [exchanges, setExchanges] = useState<Exchange[]>([])
const [showExchangeModal, setShowExchangeModal] = useState(false)
const [editingExchange, setEditingExchange] = useState<string | null>(null)
// Telegram state
const [showTelegramModal, setShowTelegramModal] = useState(false)
// Fetch data when tabs are visited
useEffect(() => {
if (activeTab === 'models') {
Promise.all([api.getModelConfigs(), api.getSupportedModels()])
.then(([configs, supported]) => {
setConfiguredModels(configs)
setSupportedModels(supported)
})
.catch(() => toast.error('Failed to load AI models'))
}
if (activeTab === 'exchanges') {
api.getExchangeConfigs()
.then(setExchanges)
.catch(() => toast.error('Failed to load exchanges'))
}
}, [activeTab])
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword.length < 8) {
toast.error('Password must be at least 8 characters')
return
}
setChangingPassword(true)
try {
const res = await fetch('/api/user/password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
},
body: JSON.stringify({ new_password: newPassword }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || 'Failed to update password')
}
toast.success('Password updated successfully')
setNewPassword('')
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update password')
} finally {
setChangingPassword(false)
}
}
const handleSaveModel = async (
modelId: string,
apiKey: string,
customApiUrl?: string,
customModelName?: string
) => {
try {
const existingModel = configuredModels.find((m) => m.id === modelId)
const modelTemplate = supportedModels.find((m) => m.id === modelId)
const modelToUpdate = existingModel || modelTemplate
if (!modelToUpdate) { toast.error('Model not found'); return }
let updatedModels: AIModel[]
if (existingModel) {
updatedModels = configuredModels.map((m) =>
m.id === modelId
? { ...m, apiKey, customApiUrl: customApiUrl || '', customModelName: customModelName || '', enabled: true }
: m
)
} else {
updatedModels = [...configuredModels, {
...modelToUpdate,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}]
}
const request = {
models: Object.fromEntries(
updatedModels.map((m) => [m.provider, {
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
}])
),
}
await toast.promise(api.updateModelConfigs(request), {
loading: 'Saving model config...',
success: 'Model config saved',
error: 'Failed to save model config',
})
const refreshed = await api.getModelConfigs()
setConfiguredModels(refreshed)
setShowModelModal(false)
setEditingModel(null)
} catch {
toast.error('Failed to save model config')
}
}
const handleDeleteModel = async (modelId: string) => {
try {
const updatedModels = configuredModels.map((m) =>
m.id === modelId ? { ...m, apiKey: '', customApiUrl: '', customModelName: '', enabled: false } : m
)
const request = {
models: Object.fromEntries(
updatedModels.map((m) => [m.provider, {
enabled: m.enabled,
api_key: m.apiKey || '',
custom_api_url: m.customApiUrl || '',
custom_model_name: m.customModelName || '',
}])
),
}
await api.updateModelConfigs(request)
const refreshed = await api.getModelConfigs()
setConfiguredModels(refreshed)
setShowModelModal(false)
setEditingModel(null)
toast.success('Model config removed')
} catch {
toast.error('Failed to remove model config')
}
}
const handleSaveExchange = async (
exchangeId: string | null,
exchangeType: string,
accountName: string,
apiKey: string,
secretKey?: string,
passphrase?: string,
testnet?: boolean,
hyperliquidWalletAddr?: string,
asterUser?: string,
asterSigner?: string,
asterPrivateKey?: string,
lighterWalletAddr?: string,
lighterPrivateKey?: string,
lighterApiKeyPrivateKey?: string,
lighterApiKeyIndex?: number
) => {
try {
if (exchangeId) {
const request = {
exchanges: {
[exchangeId]: {
enabled: true,
api_key: apiKey || '',
secret_key: secretKey || '',
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
lighter_wallet_addr: lighterWalletAddr || '',
lighter_private_key: lighterPrivateKey || '',
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
lighter_api_key_index: lighterApiKeyIndex || 0,
},
},
}
await toast.promise(api.updateExchangeConfigsEncrypted(request), {
loading: 'Updating exchange config...',
success: 'Exchange config updated',
error: 'Failed to update exchange config',
})
} else {
const createRequest = {
exchange_type: exchangeType,
account_name: accountName,
enabled: true,
api_key: apiKey || '',
secret_key: secretKey || '',
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
lighter_wallet_addr: lighterWalletAddr || '',
lighter_private_key: lighterPrivateKey || '',
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
lighter_api_key_index: lighterApiKeyIndex || 0,
}
await toast.promise(api.createExchangeEncrypted(createRequest), {
loading: 'Creating exchange account...',
success: 'Exchange account created',
error: 'Failed to create exchange account',
})
}
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
setShowExchangeModal(false)
setEditingExchange(null)
} catch {
toast.error('Failed to save exchange config')
}
}
const handleDeleteExchange = async (exchangeId: string) => {
try {
await toast.promise(api.deleteExchange(exchangeId), {
loading: 'Deleting exchange account...',
success: 'Exchange account deleted',
error: 'Failed to delete exchange account',
})
const refreshed = await api.getExchangeConfigs()
setExchanges(refreshed)
setShowExchangeModal(false)
setEditingExchange(null)
} catch {
toast.error('Failed to delete exchange account')
}
}
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
{ key: 'account', label: 'Account', icon: <User size={16} /> },
{ key: 'models', label: 'AI Models', icon: <Cpu size={16} /> },
{ key: 'exchanges', label: 'Exchanges', icon: <Building2 size={16} /> },
{ key: 'telegram', label: 'Telegram', icon: <MessageCircle size={16} /> },
]
return (
<div className="min-h-screen pt-20 pb-12 px-4" style={{ background: '#0B0E11' }}>
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Settings</h1>
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-zinc-900/60 border border-zinc-800 rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all
${activeTab === tab.key
? 'bg-nofx-gold text-black'
: 'text-zinc-400 hover:text-white'
}`}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="bg-zinc-900/60 backdrop-blur-xl border border-zinc-800/80 rounded-2xl p-6">
{/* Account Tab */}
{activeTab === 'account' && (
<div className="space-y-6">
<div>
<p className="text-xs text-zinc-500 mb-1">Email</p>
<p className="text-sm text-white font-medium">{user?.email}</p>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-white mb-4">Change Password</h3>
<form onSubmit={handleChangePassword} className="space-y-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-2">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(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="At least 8 characters"
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>
<button
type="submit"
disabled={changingPassword || newPassword.length < 8}
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"
>
{changingPassword ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
)}
{/* AI Models Tab */}
{activeTab === 'models' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{configuredModels.length} model{configuredModels.length !== 1 ? 's' : ''} configured
</p>
<button
onClick={() => { setEditingModel(null); setShowModelModal(true) }}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
Add Model
</button>
</div>
{configuredModels.length === 0 ? (
<div className="text-center py-8 text-zinc-600 text-sm">
No AI models configured yet
</div>
) : (
<div className="space-y-2">
{configuredModels.map((model) => (
<button
key={model.id}
onClick={() => { setEditingModel(model.id); setShowModelModal(true) }}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-700 flex items-center justify-center">
<Cpu size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{model.name}</p>
<p className="text-xs text-zinc-500">{model.provider}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full ${model.enabled ? 'bg-emerald-500/10 text-emerald-400' : 'bg-zinc-700 text-zinc-500'}`}>
{model.enabled ? 'Active' : 'Inactive'}
</span>
<Pencil size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Exchanges Tab */}
{activeTab === 'exchanges' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-zinc-400">
{exchanges.length} account{exchanges.length !== 1 ? 's' : ''} connected
</p>
<button
onClick={() => { setEditingExchange(null); setShowExchangeModal(true) }}
className="flex items-center gap-1.5 text-xs font-medium bg-nofx-gold/10 hover:bg-nofx-gold/20 text-nofx-gold px-3 py-1.5 rounded-lg transition-colors"
>
<Plus size={14} />
Add Exchange
</button>
</div>
{exchanges.length === 0 ? (
<div className="text-center py-8 text-zinc-600 text-sm">
No exchange accounts connected yet
</div>
) : (
<div className="space-y-2">
{exchanges.map((exchange) => (
<button
key={exchange.id}
onClick={() => { setEditingExchange(exchange.id); setShowExchangeModal(true) }}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-zinc-700 flex items-center justify-center">
<Building2 size={14} className="text-zinc-300" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{exchange.account_name || exchange.name}</p>
<p className="text-xs text-zinc-500 capitalize">{exchange.exchange_type || exchange.type}</p>
</div>
</div>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</button>
))}
</div>
)}
</div>
)}
{/* Telegram Tab */}
{activeTab === 'telegram' && (
<div className="space-y-4">
<p className="text-sm text-zinc-400">
Connect a Telegram bot to receive trading notifications and interact with your traders.
</p>
<button
onClick={() => setShowTelegramModal(true)}
className="w-full flex items-center justify-between px-4 py-3 rounded-xl bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#0088cc]/20 flex items-center justify-center">
<MessageCircle size={14} className="text-[#0088cc]" />
</div>
<span className="text-sm font-medium text-white">Configure Telegram Bot</span>
</div>
<ChevronRight size={14} className="text-zinc-600 group-hover:text-zinc-400 transition-colors" />
</button>
</div>
)}
</div>
</div>
{/* AI Model Modal */}
{showModelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
<ModelConfigModal
allModels={supportedModels}
configuredModels={configuredModels}
editingModelId={editingModel}
onSave={handleSaveModel}
onDelete={handleDeleteModel}
onClose={() => { setShowModelModal(false); setEditingModel(null) }}
language={language}
/>
</div>
)}
{/* Exchange Modal */}
{showExchangeModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
<ExchangeConfigModal
allExchanges={exchanges}
editingExchangeId={editingExchange}
onSave={handleSaveExchange}
onDelete={handleDeleteExchange}
onClose={() => { setShowExchangeModal(false); setEditingExchange(null) }}
language={language}
/>
</div>
)}
{/* Telegram Modal */}
{showTelegramModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm px-4">
<TelegramConfigModal
onClose={() => setShowTelegramModal(false)}
language={language}
/>
</div>
)}
</div>
)
}