NeonClary
Deploy Cybersecurity Panel to Hugging Face Space (flat tree for LFS).
2b2d644
Raw
History Blame Contribute Delete
23 kB
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;