Spaces:
Running
Running
| import React, { useState, useRef, useEffect, useCallback } from 'react'; | |
| import ReactDOM from 'react-dom'; | |
| import { | |
| X, User as UserIcon, Lock, Trash2, AlertTriangle, Activity, RefreshCw, Loader2, | |
| } from 'lucide-react'; | |
| const overlay = { | |
| position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, | |
| }; | |
| const modal = { | |
| background: 'var(--bg-primary)', borderRadius: 16, padding: 0, width: 560, | |
| maxWidth: '95vw', maxHeight: '85vh', overflow: 'hidden', | |
| boxShadow: 'var(--shadow-xl)', display: 'flex', flexDirection: 'column', | |
| }; | |
| const header = { | |
| display: 'flex', justifyContent: 'space-between', alignItems: 'center', | |
| padding: '20px 24px', borderBottom: '1px solid var(--border-primary)', | |
| }; | |
| const tabRow = { | |
| display: 'flex', gap: 4, padding: '12px 16px 0', | |
| borderBottom: '1px solid var(--border-primary)', | |
| flexWrap: 'wrap', | |
| }; | |
| const tabBtn = (active) => ({ | |
| display: 'flex', alignItems: 'center', gap: 8, | |
| padding: '10px 14px', background: 'transparent', | |
| border: 'none', borderBottom: active ? '2px solid var(--accent-primary)' : '2px solid transparent', | |
| color: active ? 'var(--accent-primary)' : 'var(--text-secondary)', | |
| cursor: 'pointer', fontSize: 13.5, fontWeight: 500, | |
| marginBottom: -1, | |
| }); | |
| const body = { padding: 24, overflowY: 'auto', flex: 1 }; | |
| const label = { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6 }; | |
| const input = { | |
| width: '100%', padding: '10px 12px', borderRadius: 8, | |
| border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', | |
| color: 'var(--text-primary)', fontSize: 14, boxSizing: 'border-box', | |
| }; | |
| const primaryBtn = { | |
| padding: '10px 16px', background: 'var(--accent-primary)', | |
| color: '#fff', border: 'none', borderRadius: 8, | |
| cursor: 'pointer', fontSize: 14, fontWeight: 500, | |
| }; | |
| const dangerBtn = { | |
| padding: '10px 16px', background: '#dc2626', | |
| color: '#fff', border: 'none', borderRadius: 8, | |
| cursor: 'pointer', fontSize: 14, fontWeight: 500, | |
| }; | |
| const statusColors = { | |
| online: { bg: 'rgba(22,163,74,0.12)', color: '#16a34a', border: 'rgba(22,163,74,0.35)' }, | |
| unavailable: { bg: 'rgba(234,179,8,0.12)', color: '#ca8a04', border: 'rgba(234,179,8,0.35)' }, | |
| error: { bg: 'rgba(220,38,38,0.1)', color: '#dc2626', border: 'rgba(220,38,38,0.3)' }, | |
| }; | |
| const SettingsModal = ({ | |
| user, | |
| authToken, | |
| onUserUpdate, | |
| onSignOut, | |
| onClose, | |
| initialTab = 'profile', | |
| }) => { | |
| const [activeTab, setActiveTab] = useState(initialTab || 'profile'); | |
| const mouseDownOnOverlay = useRef(false); | |
| const handleOverlayMouseDown = (e) => { | |
| mouseDownOnOverlay.current = e.target === e.currentTarget; | |
| }; | |
| const handleOverlayMouseUp = (e) => { | |
| if (mouseDownOnOverlay.current && e.target === e.currentTarget) onClose(); | |
| mouseDownOnOverlay.current = false; | |
| }; | |
| const [firstName, setFirstName] = useState(user?.firstName || ''); | |
| const [lastName, setLastName] = useState(user?.lastName || ''); | |
| const [email, setEmail] = useState(user?.email || ''); | |
| const [currentPassword, setCurrentPassword] = useState(''); | |
| const [newPassword, setNewPassword] = useState(''); | |
| const [confirmPassword, setConfirmPassword] = useState(''); | |
| const [deleteConfirmPassword, setDeleteConfirmPassword] = useState(''); | |
| const [deleteConfirmText, setDeleteConfirmText] = useState(''); | |
| const [message, setMessage] = useState(null); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [modelStatus, setModelStatus] = useState(null); | |
| const [statusLoading, setStatusLoading] = useState(false); | |
| const [statusError, setStatusError] = useState(null); | |
| const [currentProvider, setCurrentProvider] = useState(null); | |
| const [switchingProvider, setSwitchingProvider] = useState(null); | |
| const apiUrl = process.env.REACT_APP_API_URL; | |
| const extractError = (data, fallback) => { | |
| if (!data) return fallback; | |
| if (typeof data.detail === 'string') return data.detail; | |
| if (Array.isArray(data.detail) && data.detail[0]?.msg) return data.detail[0].msg; | |
| return fallback; | |
| }; | |
| const fetchModelStatus = useCallback(async (forceRefresh = false) => { | |
| setStatusLoading(true); | |
| setStatusError(null); | |
| try { | |
| const qs = forceRefresh ? '?refresh=true' : ''; | |
| const response = await fetch(`${apiUrl}/models/status${qs}`); | |
| if (!response.ok) { | |
| throw new Error(`Status request failed (${response.status})`); | |
| } | |
| const data = await response.json(); | |
| setModelStatus(data); | |
| } catch (err) { | |
| console.warn('Model status check failed; keeping unfiltered provider list.', err); | |
| setStatusError(err.message || 'Could not load model status.'); | |
| setModelStatus({ | |
| models: [], | |
| online_providers: null, | |
| check_failed: true, | |
| error: err.message || 'Network error', | |
| }); | |
| } finally { | |
| setStatusLoading(false); | |
| } | |
| }, [apiUrl]); | |
| const fetchCurrentProvider = useCallback(async () => { | |
| try { | |
| const response = await fetch(`${apiUrl}/current-provider`); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| setCurrentProvider(data.current_provider); | |
| } | |
| } catch { | |
| /* provider display is best-effort */ | |
| } | |
| }, [apiUrl]); | |
| const handleProviderSwitch = async (providerId) => { | |
| if (providerId === currentProvider || switchingProvider) return; | |
| setSwitchingProvider(providerId); | |
| setMessage(null); | |
| try { | |
| const response = await fetch(`${apiUrl}/switch-provider`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ provider: providerId }), | |
| }); | |
| const data = await response.json().catch(() => null); | |
| if (!response.ok) { | |
| setMessage({ type: 'error', text: extractError(data, `Could not switch to ${providerId}.`) }); | |
| return; | |
| } | |
| setCurrentProvider(providerId); | |
| setMessage({ type: 'success', text: `Advisors now use the ${providerId} provider.` }); | |
| } catch { | |
| setMessage({ type: 'error', text: 'Network error while switching provider.' }); | |
| } finally { | |
| setSwitchingProvider(null); | |
| } | |
| }; | |
| useEffect(() => { | |
| if (activeTab === 'model-status') { | |
| fetchModelStatus(false); | |
| fetchCurrentProvider(); | |
| } | |
| }, [activeTab, fetchModelStatus, fetchCurrentProvider]); | |
| useEffect(() => { | |
| setActiveTab(initialTab || 'profile'); | |
| }, [initialTab]); | |
| const handleProfileSubmit = async (e) => { | |
| e.preventDefault(); | |
| setMessage(null); | |
| if (!firstName.trim()) { | |
| setMessage({ type: 'error', text: 'First name is required.' }); | |
| return; | |
| } | |
| if (!email.trim()) { | |
| setMessage({ type: 'error', text: 'Email is required.' }); | |
| return; | |
| } | |
| setIsSubmitting(true); | |
| try { | |
| const payload = { | |
| firstName: firstName.trim(), | |
| lastName: lastName.trim(), | |
| }; | |
| if (email.trim() !== (user?.email || '')) { | |
| payload.email = email.trim(); | |
| } | |
| const response = await fetch(`${apiUrl}/auth/me`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${authToken}`, | |
| }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await response.json().catch(() => null); | |
| if (!response.ok) { | |
| setMessage({ type: 'error', text: extractError(data, 'Could not update profile.') }); | |
| return; | |
| } | |
| onUserUpdate?.(data); | |
| setFirstName(data.firstName || ''); | |
| setLastName(data.lastName || ''); | |
| setEmail(data.email || ''); | |
| setMessage({ type: 'success', text: 'Profile updated.' }); | |
| } catch (err) { | |
| setMessage({ type: 'error', text: 'Network error. Please try again.' }); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const handlePasswordSubmit = async (e) => { | |
| e.preventDefault(); | |
| setMessage(null); | |
| if (newPassword !== confirmPassword) { | |
| setMessage({ type: 'error', text: 'New passwords do not match.' }); | |
| return; | |
| } | |
| if (newPassword.length < 8) { | |
| setMessage({ type: 'error', text: 'New password must be at least 8 characters.' }); | |
| return; | |
| } | |
| setIsSubmitting(true); | |
| try { | |
| const response = await fetch(`${apiUrl}/auth/me/password`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${authToken}`, | |
| }, | |
| body: JSON.stringify({ | |
| current_password: currentPassword, | |
| new_password: newPassword, | |
| }), | |
| }); | |
| const data = await response.json().catch(() => null); | |
| if (!response.ok) { | |
| setMessage({ type: 'error', text: extractError(data, 'Could not change password.') }); | |
| return; | |
| } | |
| setCurrentPassword(''); | |
| setNewPassword(''); | |
| setConfirmPassword(''); | |
| setMessage({ type: 'success', text: 'Password changed.' }); | |
| } catch (err) { | |
| setMessage({ type: 'error', text: 'Network error. Please try again.' }); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const handleDeleteAccount = async (e) => { | |
| e.preventDefault(); | |
| setMessage(null); | |
| if (deleteConfirmText !== 'DELETE') { | |
| setMessage({ type: 'error', text: 'Type DELETE to confirm.' }); | |
| return; | |
| } | |
| if (!deleteConfirmPassword) { | |
| setMessage({ type: 'error', text: 'Password required to delete account.' }); | |
| return; | |
| } | |
| setIsSubmitting(true); | |
| try { | |
| const response = await fetch(`${apiUrl}/auth/me`, { | |
| method: 'DELETE', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${authToken}`, | |
| }, | |
| body: JSON.stringify({ password: deleteConfirmPassword }), | |
| }); | |
| const data = await response.json().catch(() => null); | |
| if (!response.ok) { | |
| setMessage({ type: 'error', text: extractError(data, 'Could not delete account.') }); | |
| return; | |
| } | |
| onClose?.(); | |
| onSignOut?.(); | |
| } catch (err) { | |
| setMessage({ type: 'error', text: 'Network error. Please try again.' }); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const messageStyle = (type) => ({ | |
| padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13, | |
| background: type === 'error' | |
| ? 'rgba(220,38,38,0.1)' | |
| : type === 'success' | |
| ? 'rgba(22,163,74,0.1)' | |
| : 'var(--bg-secondary)', | |
| color: type === 'error' | |
| ? '#dc2626' | |
| : type === 'success' | |
| ? '#16a34a' | |
| : 'var(--text-secondary)', | |
| border: `1px solid ${ | |
| type === 'error' | |
| ? 'rgba(220,38,38,0.3)' | |
| : type === 'success' | |
| ? 'rgba(22,163,74,0.3)' | |
| : 'var(--border-primary)' | |
| }`, | |
| }); | |
| const switchTab = (tab) => { | |
| setActiveTab(tab); | |
| setMessage(null); | |
| }; | |
| return ReactDOM.createPortal( | |
| <div style={overlay} onMouseDown={handleOverlayMouseDown} onMouseUp={handleOverlayMouseUp}> | |
| <div style={modal}> | |
| <div style={header}> | |
| <h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 18 }}>Account Settings</h3> | |
| <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)' }}> | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| <div style={tabRow}> | |
| <button style={tabBtn(activeTab === 'profile')} onClick={() => switchTab('profile')}> | |
| <UserIcon size={15} /> Profile | |
| </button> | |
| <button style={tabBtn(activeTab === 'password')} onClick={() => switchTab('password')}> | |
| <Lock size={15} /> Password | |
| </button> | |
| <button style={tabBtn(activeTab === 'danger')} onClick={() => switchTab('danger')}> | |
| <Trash2 size={15} /> Delete Account | |
| </button> | |
| <button style={tabBtn(activeTab === 'model-status')} onClick={() => switchTab('model-status')}> | |
| <Activity size={15} /> Model Status | |
| </button> | |
| </div> | |
| <div style={body}> | |
| {message && <div style={messageStyle(message.type)}>{message.text}</div>} | |
| {activeTab === 'profile' && ( | |
| <form onSubmit={handleProfileSubmit}> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>Email</label> | |
| {user?.is_guest ? ( | |
| <input style={{ ...input, opacity: 0.6, cursor: 'not-allowed' }} value={user?.email || ''} disabled /> | |
| ) : ( | |
| <input type="email" style={input} value={email} onChange={(e) => setEmail(e.target.value)} /> | |
| )} | |
| </div> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}> | |
| <div> | |
| <label style={label}>First Name</label> | |
| <input style={input} value={firstName} onChange={(e) => setFirstName(e.target.value)} /> | |
| </div> | |
| <div> | |
| <label style={label}>Last Name</label> | |
| <input style={input} value={lastName} onChange={(e) => setLastName(e.target.value)} /> | |
| </div> | |
| </div> | |
| <button type="submit" style={primaryBtn} disabled={isSubmitting}> | |
| {isSubmitting ? 'Saving…' : 'Save Changes'} | |
| </button> | |
| </form> | |
| )} | |
| {activeTab === 'password' && ( | |
| <form onSubmit={handlePasswordSubmit}> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>Current Password</label> | |
| <input type="password" style={input} value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required /> | |
| </div> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>New Password</label> | |
| <input type="password" style={input} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required /> | |
| </div> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>Confirm New Password</label> | |
| <input type="password" style={input} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required /> | |
| </div> | |
| <button type="submit" style={primaryBtn} disabled={isSubmitting}> | |
| {isSubmitting ? 'Changing…' : 'Change Password'} | |
| </button> | |
| </form> | |
| )} | |
| {activeTab === 'danger' && ( | |
| <form onSubmit={handleDeleteAccount}> | |
| <div style={{ | |
| display: 'flex', gap: 10, padding: 12, borderRadius: 8, | |
| background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.3)', | |
| marginBottom: 16, | |
| }}> | |
| <AlertTriangle size={18} style={{ color: '#dc2626', flexShrink: 0, marginTop: 2 }} /> | |
| <div style={{ fontSize: 13, color: 'var(--text-primary)' }}> | |
| Deleting your account is permanent. All chat history and personal data will be removed. | |
| </div> | |
| </div> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>Confirm Password</label> | |
| <input type="password" style={input} value={deleteConfirmPassword} onChange={(e) => setDeleteConfirmPassword(e.target.value)} required /> | |
| </div> | |
| <div style={{ marginBottom: 16 }}> | |
| <label style={label}>Type <strong>DELETE</strong> to confirm</label> | |
| <input style={input} value={deleteConfirmText} onChange={(e) => setDeleteConfirmText(e.target.value)} placeholder="DELETE" required /> | |
| </div> | |
| <button type="submit" style={dangerBtn} disabled={isSubmitting}> | |
| {isSubmitting ? 'Deleting…' : 'Permanently Delete Account'} | |
| </button> | |
| </form> | |
| )} | |
| {activeTab === 'model-status' && ( | |
| <div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, gap: 12 }}> | |
| <div style={{ fontSize: 13, color: 'var(--text-secondary)' }}> | |
| Probe each configured API with a tiny chat request. | |
| {modelStatus?.cached ? ' Showing cached results.' : null} | |
| {modelStatus?.checked_at ? ( | |
| <span> Last checked: {new Date(modelStatus.checked_at).toLocaleString()}</span> | |
| ) : null} | |
| </div> | |
| <button | |
| type="button" | |
| style={{ ...primaryBtn, display: 'inline-flex', alignItems: 'center', gap: 8, flexShrink: 0 }} | |
| onClick={() => fetchModelStatus(true)} | |
| disabled={statusLoading} | |
| > | |
| {statusLoading | |
| ? <Loader2 size={15} className="spinning" style={{ animation: 'spin 1s linear infinite' }} /> | |
| : <RefreshCw size={15} />} | |
| Refresh | |
| </button> | |
| </div> | |
| {statusError && ( | |
| <div style={messageStyle('error')}> | |
| Status check failed — provider list left unfiltered. {statusError} | |
| </div> | |
| )} | |
| {modelStatus?.check_failed && !statusError && ( | |
| <div style={messageStyle('error')}> | |
| Status check failed — provider list left unfiltered. | |
| {modelStatus.error ? ` ${modelStatus.error}` : ''} | |
| </div> | |
| )} | |
| {statusLoading && !modelStatus?.models?.length && ( | |
| <div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Checking models…</div> | |
| )} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> | |
| {(modelStatus?.models || []).map((m) => { | |
| const tone = statusColors[m.status] || statusColors.unavailable; | |
| const isActive = m.provider === currentProvider; | |
| // Fail closed per model: only online + selectable providers can | |
| // be activated. Fail open if the whole check failed (no rows | |
| // render in that case, so nothing is blocked). | |
| const canActivate = m.status === 'online' && m.selectable !== false && !isActive; | |
| return ( | |
| <div | |
| key={m.id} | |
| style={{ | |
| padding: '12px 14px', | |
| borderRadius: 10, | |
| border: `1px solid ${isActive ? 'var(--accent-primary)' : tone.border}`, | |
| background: 'var(--bg-secondary)', | |
| }} | |
| > | |
| <div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}> | |
| <div style={{ minWidth: 0 }}> | |
| <div style={{ fontWeight: 600, color: 'var(--text-primary)', fontSize: 14 }}> | |
| {m.name} | |
| {m.model ? ( | |
| <span style={{ fontWeight: 400, color: 'var(--text-secondary)', marginLeft: 8, fontSize: 12 }}> | |
| {m.model} | |
| </span> | |
| ) : null} | |
| </div> | |
| <div style={{ fontSize: 12, color: 'var(--text-secondary)', marginTop: 2 }}> | |
| {m.provider} | |
| {typeof m.latency_ms === 'number' ? ` · ${m.latency_ms} ms` : ''} | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> | |
| <span style={{ | |
| fontSize: 12, | |
| fontWeight: 600, | |
| textTransform: 'uppercase', | |
| letterSpacing: '0.03em', | |
| padding: '4px 8px', | |
| borderRadius: 6, | |
| background: tone.bg, | |
| color: tone.color, | |
| border: `1px solid ${tone.border}`, | |
| }}> | |
| {m.status} | |
| </span> | |
| {isActive ? ( | |
| <span style={{ | |
| fontSize: 12, | |
| fontWeight: 600, | |
| padding: '4px 10px', | |
| borderRadius: 6, | |
| background: 'var(--accent-primary)', | |
| color: '#fff', | |
| }}> | |
| Active | |
| </span> | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={() => handleProviderSwitch(m.provider)} | |
| disabled={!canActivate || !!switchingProvider} | |
| style={{ | |
| fontSize: 12, | |
| fontWeight: 600, | |
| padding: '4px 10px', | |
| borderRadius: 6, | |
| border: '1px solid var(--border-primary)', | |
| background: 'transparent', | |
| color: canActivate ? 'var(--accent-primary)' : 'var(--text-secondary)', | |
| cursor: canActivate && !switchingProvider ? 'pointer' : 'not-allowed', | |
| opacity: canActivate ? 1 : 0.5, | |
| }} | |
| > | |
| {switchingProvider === m.provider ? 'Switching…' : 'Use'} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {m.status === 'error' && m.error && ( | |
| <div style={{ | |
| marginTop: 8, | |
| fontSize: 12, | |
| color: '#dc2626', | |
| wordBreak: 'break-word', | |
| fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', | |
| }}> | |
| {m.error} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div>, | |
| document.body | |
| ); | |
| }; | |
| export default SettingsModal; | |