pdf-quiz-generator/frontend/src/pages/AdminPage.jsx
Daniel 2cbbfe00c3 Tag filtering, multi-category, bug fixes, image validation, docs
- Fix tag filtering (sa_text import shadowing caused UnboundLocalError)
- Add TagBrowser component with per-section search
- Multi-category selection (OR within categories, AND with tags)
- AI image validation: has_figure field in extraction prompt
- Skip known branding images by MD5 hash + dimension filters
- Fix quiz timer auto-submit (wrong useEffect dependency)
- Fix QuizResponse schema: section_id nullable
- Fix Question.quiz_id → source_quiz_id attribute name
- Fix SQL injection in quizzes.py vector search
- Add PDF processing progress steps via Redis
- Add delete user from admin panel
- Admin page: no spinner flash on data refresh
- Upload progress: axios 1.x e.progress, remove manual Content-Type
- Duplicate model error: 409 with clear message
- Backend startup: retry DDL migration on lock timeout
- Replace all silent except:pass with warning logs
- Comprehensive multi-page documentation (docs/)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:48:26 +02:00

809 lines
38 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword']
export default function AdminPage() {
const { user } = useAuth()
const navigate = useNavigate()
const { dialogProps, openConfirm } = useDialog()
const [tab, setTab] = useState('models')
const [users, setUsers] = useState([])
const [models, setModels] = useState([])
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
// LiteLLM search state
const [searchApiKey, setSearchApiKey] = useState('')
const [searchApiBase, setSearchApiBase] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('')
const [searchFilter, setSearchFilter] = useState('')
const [searchTaskHint, setSearchTaskHint] = useState('extraction')
// TTS voice discovery state
const [ttsProvider, setTtsProvider] = useState('elevenlabs')
const [ttsSearchKey, setTtsSearchKey] = useState('')
const [ttsVoices, setTtsVoices] = useState([])
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
const [ttsVoicesError, setTtsVoicesError] = useState('')
const [ttsVoicesFilter, setTtsVoicesFilter] = useState('')
// TTS preview state per model db id: {text, loading, error, audioUrl}
const [ttsPreviews, setTtsPreviews] = useState({})
// Embedding model search state (in settings tab)
const [embedSearchResults, setEmbedSearchResults] = useState([])
const [embedSearchLoading, setEmbedSearchLoading] = useState(false)
const [embedSearchError, setEmbedSearchError] = useState('')
const [embedSearchFilter, setEmbedSearchFilter] = useState('')
const [embedTestResult, setEmbedTestResult] = useState(null)
const [embedTestLoading, setEmbedTestLoading] = useState(false)
const [originalEmbedModel, setOriginalEmbedModel] = useState(null)
const [embedModelChanged, setEmbedModelChanged] = useState(false)
const [regenLoading, setRegenLoading] = useState(false)
useEffect(() => {
if (!user?.role || user.role !== 'admin') { navigate('/'); return }
loadData()
}, [user])
const loadData = async (showSpinner = true) => {
if (showSpinner) setLoading(true)
try {
const [usersRes, modelsRes, settingsRes] = await Promise.all([
api.get('/admin/users'),
api.get('/admin/models'),
api.get('/admin/settings'),
])
setUsers(usersRes.data)
setModels(modelsRes.data)
setSettings(settingsRes.data)
setOriginalEmbedModel(settingsRes.data.embedding_model || '')
} catch (err) {
setError(err.response?.data?.detail || 'Failed to load data')
} finally {
setLoading(false)
}
}
const updateRole = async (userId, role) => {
try {
await api.put(`/admin/users/${userId}/role`, { role })
setSuccess(`Role updated to ${role}`)
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update role')
}
}
const deleteUser = async (userId, userName) => {
const ok = await openConfirm(`Delete user "${userName}"? This removes all their data (attempts, favorites, settings).`, { title: 'Delete User', confirmLabel: 'Delete', danger: true })
if (!ok) return
try {
await api.delete(`/admin/users/${userId}`)
setSuccess(`User "${userName}" deleted`)
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete user')
}
}
const toggleUnthrottle = async (userId, currentVal) => {
try {
await api.put(`/admin/users/${userId}/unthrottle`, { unthrottled: !currentVal })
setSuccess(currentVal ? 'Rate limits restored for user' : 'Rate limits removed for user')
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update throttle setting')
}
}
const createModel = async (e) => {
e.preventDefault()
setError('')
try {
await api.post('/admin/models', newModel)
setSuccess('Model added')
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to add model')
}
}
const setDefault = async (modelId) => {
try {
await api.put(`/admin/models/${modelId}`, { is_default: true })
setSuccess('Default model updated')
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
}
const toggleActive = async (model) => {
try {
await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active })
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update')
}
}
const deleteModel = async (modelId) => {
const ok = await openConfirm('Remove this model configuration?', { title: 'Remove Model', confirmLabel: 'Remove', danger: true })
if (!ok) return
try {
await api.delete(`/admin/models/${modelId}`)
loadData(false)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to delete')
}
}
const [testResults, setTestResults] = useState({})
const [testingModel, setTestingModel] = useState(null)
const testModel = async (model) => {
setTestingModel(model.id)
setTestResults(r => ({ ...r, [model.id]: null }))
try {
const res = await api.post(`/admin/models/${model.id}/test`)
setTestResults(r => ({ ...r, [model.id]: { ok: true, message: res.data.message || 'OK' } }))
} catch (err) {
setTestResults(r => ({ ...r, [model.id]: { ok: false, message: err.response?.data?.detail || 'Test failed' } }))
} finally {
setTestingModel(null)
}
}
const searchLiteLLM = async () => {
setSearchError('')
setSearchResults([])
setSearchLoading(true)
try {
const res = await api.post('/admin/litellm/models', {
api_key: searchApiKey || null,
api_base: searchApiBase || null,
})
setSearchResults(res.data.models)
} catch (err) {
setSearchError(err.response?.data?.detail || 'Failed to query models')
} finally {
setSearchLoading(false)
}
}
const searchEmbeddingModels = async () => {
setEmbedSearchError('')
setEmbedSearchResults([])
setEmbedSearchLoading(true)
try {
const res = await api.post('/admin/litellm/models', {})
const all = res.data.models || []
// Filter to likely embedding models
setEmbedSearchResults(all)
} catch (err) {
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
} finally {
setEmbedSearchLoading(false)
}
}
const testEmbedding = async () => {
setEmbedTestResult(null)
setEmbedTestLoading(true)
try {
const res = await api.post('/admin/embedding/test')
setEmbedTestResult({ ok: true, ...res.data })
} catch (err) {
setEmbedTestResult({ ok: false, error: err.response?.data?.detail || 'Test failed' })
} finally {
setEmbedTestLoading(false)
}
}
const saveEmbeddingModel = async (modelId) => {
try {
await api.put('/admin/settings', { embedding_model: modelId })
setSettings(s => ({ ...s, embedding_model: modelId }))
setEmbedSearchResults([])
setSuccess(`Embedding model set to: ${modelId}`)
if (originalEmbedModel && modelId !== originalEmbedModel) {
setEmbedModelChanged(true)
}
} catch (err) {
setError(err.response?.data?.detail || 'Failed to save embedding model')
}
}
const startRegeneration = async () => {
setRegenLoading(true)
try {
await api.post('/admin/embedding/regenerate')
setEmbedModelChanged(false)
setSuccess('Regeneration started — watch the Jobs badge for progress.')
} catch (err) {
setError(err.response?.data?.detail || 'Failed to start regeneration')
} finally {
setRegenLoading(false)
}
}
const addFromSearch = (modelId) => {
setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint }))
setSearchResults([])
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
}
// ── TTS voice discovery ─────────────────────────────────────────
const searchTtsVoices = async () => {
setTtsVoicesError('')
setTtsVoices([])
setTtsVoicesLoading(true)
try {
const res = await api.post('/admin/tts/voices', {
provider: ttsProvider,
api_key: ttsSearchKey || null,
})
setTtsVoices(res.data.voices)
} catch (err) {
setTtsVoicesError(err.response?.data?.detail || 'Failed to fetch voices')
} finally {
setTtsVoicesLoading(false)
}
}
const addTtsFromSearch = (voice) => {
setNewModel(m => ({ ...m, model_id: voice.model_id, name: voice.name, task: 'tts' }))
setTtsVoices([])
document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' })
}
// ── TTS preview (calls /tts/speak directly) ─────────────────────
const setPreview = (id, patch) =>
setTtsPreviews(p => ({ ...p, [id]: { text: 'Hello, this is a voice preview.', ...p[id], ...patch } }))
const playTtsPreview = async (model) => {
const state = ttsPreviews[model.id] || {}
const text = state.text || 'Hello, this is a voice preview.'
setPreview(model.id, { loading: true, error: null })
try {
const res = await api.post('/tts/speak', { text, voice: model.model_id }, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const audio = new Audio(url)
audio.onended = () => URL.revokeObjectURL(url)
audio.play()
setPreview(model.id, { loading: false })
} catch (err) {
setPreview(model.id, { loading: false, error: err.response?.data?.detail || 'Playback failed' })
}
}
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
const modelsByTask = TASKS.reduce((acc, t) => {
acc[t] = models.filter(m => m.task === t)
return acc
}, {})
const filteredResults = searchFilter
? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase()))
: searchResults
return (
<div>
<Dialog {...dialogProps} />
<div className="card">
<h2>Admin Dashboard</h2>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
{['models', 'users', 'settings'].map(t => (
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
</button>
))}
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}
{success && <div className="alert alert-success">{success}</div>}
{tab === 'models' && (
<>
{/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */}
<div className="card">
<h2>Search LLM Models</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks.
</p>
<div className="grid-2">
<div className="form-group">
<label>API Base URL (optional uses .env if blank)</label>
<input
type="text"
value={searchApiBase}
onChange={e => setSearchApiBase(e.target.value)}
placeholder="e.g. https://litellm.myserver.com"
/>
</div>
<div className="form-group">
<label>API Key (optional uses .env if blank)</label>
<input
type="password"
value={searchApiKey}
onChange={e => setSearchApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div className="form-group">
<label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
{TASKS.filter(t => t !== 'tts').map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
{searchLoading ? 'Searching...' : 'Search Models'}
</button>
{searchError && <div className="alert alert-error" style={{ marginTop: 12 }}>{searchError}</div>}
{searchResults.length > 0 && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong>{searchResults.length} models found</strong>
<input
type="text"
placeholder="Filter..."
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }}
/>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 8 }}>
{filteredResults.map(modelId => (
<div
key={modelId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid #f1f5f9',
fontSize: '0.85rem', fontFamily: 'monospace',
}}
>
<span>{modelId}</span>
<button
className="btn btn-primary btn-sm"
onClick={() => addFromSearch(modelId)}
style={{ fontFamily: 'inherit' }}
>
+ Add
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* Configured models by task */}
{TASKS.map(task => (
<div className="card" key={task}>
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
{task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' :
'— General purpose AI'}
</span>
</h2>
{/* TTS voice discovery — only in the tts card */}
{task === 'tts' && (
<div style={{ marginBottom: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
<div style={{ fontWeight: 600, fontSize: '0.85rem', marginBottom: 8 }}>Discover Voices</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="elevenlabs">ElevenLabs</option>
<option value="polly">AWS Polly (Neural)</option>
<option value="openai">OpenAI TTS</option>
</select>
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
placeholder="API key (uses .env if blank)"
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
{ttsVoicesLoading ? 'Searching...' : 'Search'}
</button>
</div>
{ttsVoicesError && <div className="alert alert-error" style={{ fontSize: '0.82rem', padding: '6px 12px' }}>{ttsVoicesError}</div>}
{ttsVoices.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{ttsVoices.length} voices click to add</span>
<input type="text" placeholder="Filter..." value={ttsVoicesFilter} onChange={e => setTtsVoicesFilter(e.target.value)}
style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.82rem', width: 160, background: 'var(--input-bg)', color: 'var(--text)' }} />
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
{ttsVoices
.filter(v => !ttsVoicesFilter || v.name.toLowerCase().includes(ttsVoicesFilter.toLowerCase()) || v.model_id.toLowerCase().includes(ttsVoicesFilter.toLowerCase()))
.map(v => (
<div key={v.model_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', borderBottom: '1px solid var(--border)', fontSize: '0.83rem' }}>
<div>
<span style={{ fontWeight: 500 }}>{v.name}</span>
{v.labels && Object.keys(v.labels).length > 0 && (
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: '0.75rem' }}>
{Object.values(v.labels).filter(Boolean).join(' · ')}
</span>
)}
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'var(--text-muted)' }}>{v.model_id}</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => addTtsFromSearch(v)}>+ Add</button>
</div>
))}
</div>
</div>
)}
</div>
)}
{modelsByTask[task].length === 0 ? (
<div className="empty-state" style={{ padding: 16 }}>No models configured</div>
) : (
modelsByTask[task].map(m => (
<div key={m.id}>
<div className="section-item">
<div style={{ minWidth: 0 }}>
<strong>{m.name}</strong>
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
{m.is_default && <span className="badge badge-ready">Default</span>}
{!m.is_default && (
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
)}
{task === 'tts' ? (
<button
className="btn btn-secondary btn-sm"
onClick={() => setPreview(m.id, { open: !(ttsPreviews[m.id]?.open) })}
>Preview</button>
) : (
<button
className="btn btn-secondary btn-sm"
onClick={() => testModel(m)}
disabled={testingModel === m.id}
>{testingModel === m.id ? 'Testing...' : 'Test'}</button>
)}
<button
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => toggleActive(m)}
>{m.is_active ? 'Disable' : 'Enable'}</button>
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
</div>
</div>
{/* LLM test result */}
{testResults[m.id] && (
<div className={`alert ${testResults[m.id].ok ? 'alert-success' : 'alert-error'}`} style={{ marginTop: 6, marginBottom: 0, padding: '6px 12px', fontSize: '0.82rem' }}>
{testResults[m.id].ok ? '✓ ' : '✗ '}{testResults[m.id].message}
</div>
)}
{/* TTS preview */}
{task === 'tts' && ttsPreviews[m.id]?.open && (
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg)', borderRadius: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<textarea
value={ttsPreviews[m.id]?.text ?? 'Hello, this is a voice preview.'}
onChange={e => setPreview(m.id, { text: e.target.value })}
rows={2}
style={{ flex: 1, minWidth: 160, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', resize: 'none', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }}
/>
<button className="btn btn-primary btn-sm" style={{ alignSelf: 'flex-end' }}
onClick={() => playTtsPreview(m)}
disabled={ttsPreviews[m.id]?.loading}>
{ttsPreviews[m.id]?.loading ? '⏳' : '▶ Play'}
</button>
{ttsPreviews[m.id]?.error && (
<div className="alert alert-error" style={{ width: '100%', marginTop: 4, padding: '5px 10px', fontSize: '0.82rem' }}>
{ttsPreviews[m.id].error}
</div>
)}
</div>
)}
</div>
))
)}
</div>
))}
{/* Add model form */}
<div className="card" id="add-model-form">
<h2>Add Model</h2>
<form onSubmit={createModel}>
<div className="grid-2">
<div className="form-group">
<label>Task</label>
<select value={newModel.task} onChange={e => setNewModel(m => ({ ...m, task: e.target.value }))}>
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-group">
<label>Display Name</label>
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
</div>
<div className="form-group">
<label>Model ID (LiteLLM format)</label>
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
</div>
<div className="form-group">
<label>API Key (optional override)</label>
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
</div>
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
Set as default
</label>
</div>
</div>
<button className="btn btn-primary" type="submit">Add Model</button>
</form>
</div>
</>
)}
{tab === 'users' && (
<>
<div className="card">
<h2>Add User</h2>
<form onSubmit={async e => {
e.preventDefault(); setError(''); setSuccess('')
try {
await api.post('/admin/users', newUser)
setSuccess(`User ${newUser.email} created`)
setNewUser({ name: '', email: '', password: '' })
loadData(false)
} catch (err) { setError(err.response?.data?.detail || 'Failed to create user') }
}}>
<div className="grid-2">
<div className="form-group">
<label>Name</label>
<input type="text" value={newUser.name} onChange={e => setNewUser(u => ({ ...u, name: e.target.value }))} required />
</div>
<div className="form-group">
<label>Email</label>
<input type="email" value={newUser.email} onChange={e => setNewUser(u => ({ ...u, email: e.target.value }))} required />
</div>
<div className="form-group">
<label>Password</label>
<input type="password" value={newUser.password} onChange={e => setNewUser(u => ({ ...u, password: e.target.value }))} required minLength={8} />
</div>
</div>
<button className="btn btn-primary" type="submit">Create User</button>
</form>
</div>
<div className="card">
<h2>Users</h2>
{users.map(u => (
<div className="section-item" key={u.id}>
<div>
<strong>{u.name}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{u.email} · joined {new Date(u.created_at).toLocaleDateString()}</div>
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<span className={`badge ${u.role === 'admin' ? 'badge-error' : u.role === 'moderator' ? 'badge-processing' : 'badge-ready'}`}>
{u.role}
</span>
{u.is_unthrottled ? (
<span style={{ fontSize: '0.72rem', padding: '2px 7px', borderRadius: 10, background: '#fef9c3', color: '#92400e', fontWeight: 600 }}>
unlimited
</span>
) : null}
<select
value={u.role}
onChange={e => updateRole(u.id, e.target.value)}
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem' }}
>
<option value="user">user</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button
className="btn btn-secondary btn-sm"
title={u.is_unthrottled ? 'Restore rate limits' : 'Remove rate limits for this user'}
onClick={() => toggleUnthrottle(u.id, u.is_unthrottled)}
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
>
{u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'}
</button>
{u.id !== user?.id && (
<button
className="btn btn-danger btn-sm"
onClick={() => deleteUser(u.id, u.name || u.email)}
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
>
Delete
</button>
)}
</div>
</div>
))}
</div>
</>
)}
{tab === 'settings' && (
<div className="card">
<h2>More Settings</h2>
<div style={{ marginTop: 16 }}>
{/* Public Registration */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>Public Registration</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Allow new users to create accounts. Admins can always create users manually.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.registration_enabled}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, registration_enabled: enabled }))
try {
await api.put('/admin/settings', { registration_enabled: enabled })
setSuccess(`Registration ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, registration_enabled: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.registration_enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
{/* AWS Polly */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.polly_enabled}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, polly_enabled: enabled }))
try {
await api.put('/admin/settings', { polly_enabled: enabled })
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, polly_enabled: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
{/* Embedding model change warning */}
{embedModelChanged && (
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>
<strong style={{ color: '#92400e', display: 'block', marginBottom: 6 }}>
Embedding model changed
</strong>
<span style={{ fontSize: '0.85rem', color: '#78350f' }}>
Existing question embeddings were generated with the previous model and may produce inaccurate semantic search results.
Regenerate them to restore full search quality.
</span>
<div style={{ marginTop: 10 }}>
<button className="btn btn-primary btn-sm" onClick={startRegeneration} disabled={regenLoading}>
{regenLoading ? 'Starting…' : 'Regenerate All Embeddings'}
</button>
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 8 }} onClick={() => setEmbedModelChanged(false)}>
Dismiss
</button>
</div>
</div>
)}
{/* Embedding Model */}
<div style={{ padding: '16px 0' }}>
<strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 12 }}>
Model used to generate semantic search vectors. Must output {1024} dimensions to match the database schema.
Current: <code style={{ background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 4 }}>{settings.embedding_model || '(not set)'}</code>
</span>
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<input
type="text"
value={settings.embedding_model}
onChange={e => setSettings(s => ({ ...s, embedding_model: e.target.value }))}
placeholder="e.g. ge-gemini-embedding-001"
style={{ flex: 1, minWidth: 0 }}
/>
<button className="btn btn-primary btn-sm" onClick={() => saveEmbeddingModel(settings.embedding_model)}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={searchEmbeddingModels} disabled={embedSearchLoading}>
{embedSearchLoading ? 'Searching...' : 'Search LiteLLM'}
</button>
<button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}>
{embedTestLoading ? 'Testing...' : 'Test'}
</button>
<button className="btn btn-secondary btn-sm" onClick={startRegeneration} disabled={regenLoading} title="Regenerate embeddings for all questions">
{regenLoading ? 'Starting…' : 'Regenerate All'}
</button>
</div>
{embedTestResult && (
<div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}>
{embedTestResult.ok
? `OK — ${embedTestResult.model} · ${embedTestResult.dimensions} dims`
: embedTestResult.error}
</div>
)}
{embedSearchError && <div className="alert alert-error" style={{ marginBottom: 8 }}>{embedSearchError}</div>}
{embedSearchResults.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{embedSearchResults.length} embedding models found click to select</span>
<input
type="text"
placeholder="Filter..."
value={embedSearchFilter}
onChange={e => setEmbedSearchFilter(e.target.value)}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 180 }}
/>
</div>
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
{embedSearchResults
.filter(m => !embedSearchFilter || m.toLowerCase().includes(embedSearchFilter.toLowerCase()))
.map(modelId => (
<div
key={modelId}
style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderBottom: '1px solid var(--border)',
fontSize: '0.85rem', fontFamily: 'monospace',
}}
>
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelId}</span>
<button className="btn btn-primary btn-sm" style={{ flexShrink: 0 }} onClick={() => saveEmbeddingModel(modelId)}>
Select
</button>
</div>
))
}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}