refactor(settings): People and models rebuilt natively; AdminPage is gone
The boxing was structural, not cosmetic. AdminSection wrapped AdminPage
in a Settings panel while AdminPage brought its own .card wrappers — a
box inside a box — and SettingsPage.css patched the symptom with
.set-admin > div > .card { border: 0 }. Both sections are written as
Settings sections now, the patch is deleted, and AdminPage with it.
People: no more refetch-everything. The old page called loadData() after
every change, pulling users, models and settings together and
re-rendering the section, which is what read as a page reload — there
was never a location.reload() to find. A change now updates the one row
it touched from what the server returned. The list searches, scrolls in
a bounded box, and will not let you change or delete your own account.
Models: the selection is rebuilt rather than restyled. Seven cards of
rows, each with Default, Test, Disable and Remove, meant four buttons to
express one fact. There are two decisions, so there are two places now —
which models the site may use at all is an allow-list you set once and
fold away, and which one does a given job is a single choice made where
the job is named. A job with one model offers no choice, because there
is none to make.
The embedding model moved in with the models, since it is one, and
Search — which held nothing else — is retired.
Settings fills the window: the panel column scrolls inside itself, so a
short section no longer ends two thirds of the way down a tall screen
and a long one no longer runs off the bottom. Below 700px the page
scrolls as one, as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
3d379d41a8
commit
c59f9f936b
10 changed files with 1001 additions and 766 deletions
115
frontend/src/components/ModelsAdmin.css
Normal file
115
frontend/src/components/ModelsAdmin.css
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
.mdl { display: flex; flex-direction: column; gap: 16px; }
|
||||
.mdl-error { margin: 0; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||
.mdl-notice { margin: 0; font-size: 0.85rem; color: var(--right-fg, #15803d); }
|
||||
.mdl-empty { padding: 12px 4px; font-size: 0.84rem; color: var(--text-muted); }
|
||||
|
||||
/* ── One job, one choice ──────────────────────────────────────────── */
|
||||
.mdl-jobs { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.mdl-jobs > li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 14px; flex-wrap: wrap;
|
||||
padding: 12px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mdl-jobs > li:last-child { border-bottom: 0; }
|
||||
.mdl-job { min-width: 0; }
|
||||
.mdl-job strong { display: block; font-size: 0.92rem; font-weight: 650; }
|
||||
.mdl-job small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); }
|
||||
|
||||
.mdl-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.mdl-pick select {
|
||||
min-width: 200px; max-width: 100%; padding: 6px 10px;
|
||||
font: inherit; font-size: 0.84rem;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
.mdl-pick code {
|
||||
font-size: 0.82rem; padding: 4px 9px;
|
||||
background: var(--bg); border-radius: 6px; color: var(--text);
|
||||
}
|
||||
.mdl-none { font-size: 0.81rem; color: var(--text-muted); font-style: italic; }
|
||||
.mdl-test, .mdl-remove {
|
||||
padding: 5px 11px; font: inherit; font-size: 0.78rem; font-weight: 600;
|
||||
cursor: pointer; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
|
||||
}
|
||||
.mdl-test:disabled, .mdl-remove:disabled { opacity: 0.5; cursor: default; }
|
||||
.mdl-remove { color: var(--wrong-fg); border-color: var(--wrong-bd); }
|
||||
.mdl-remove:hover:not(:disabled) { background: var(--wrong-bg); }
|
||||
.mdl-result { flex-basis: 100%; margin: 2px 0 0; font-size: 0.79rem; }
|
||||
.mdl-result.is-ok { color: var(--right-fg, #15803d); }
|
||||
.mdl-result.is-bad { color: var(--wrong-fg); }
|
||||
|
||||
/* ── The allow-list, folded away ──────────────────────────────────── */
|
||||
.mdl-allow { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.mdl-allow-head {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
width: 100%; padding: 12px 14px;
|
||||
font: inherit; text-align: left; cursor: pointer;
|
||||
background: var(--card-bg); border: 0; color: var(--text);
|
||||
}
|
||||
.mdl-allow-head:hover { background: var(--bg); }
|
||||
.mdl-allow-head strong { display: block; font-size: 0.9rem; font-weight: 650; }
|
||||
.mdl-allow-head small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); }
|
||||
.mdl-allow-body { padding: 4px 14px 16px; border-top: 1px solid var(--border); }
|
||||
.mdl-allow-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.mdl-allow-bar label { display: inline-flex; align-items: center; gap: 7px; font-size: 0.83rem; }
|
||||
.mdl-allow-bar select {
|
||||
padding: 5px 9px; font: inherit; font-size: 0.83rem;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
.mdl-allow-body input[type="search"] {
|
||||
width: 100%; margin: 12px 0 8px; padding: 8px 11px;
|
||||
/* 16px on touch: iOS zooms in on anything smaller and never zooms back. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .mdl-allow-body input[type="search"] { font-size: 0.86rem; } }
|
||||
|
||||
.mdl-current, .mdl-offered { list-style: none; margin: 0; padding: 0; }
|
||||
.mdl-current > li, .mdl-offered > li {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 7px 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mdl-current > li:last-child, .mdl-offered > li:last-child { border-bottom: 0; }
|
||||
.mdl-current code, .mdl-offered code {
|
||||
flex: 1; min-width: 0; font-size: 0.8rem;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
/* The proxy answers with hundreds; this one is bounded. */
|
||||
.mdl-offered { max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 2px 11px; }
|
||||
.mdl-offered button {
|
||||
padding: 4px 11px; font: inherit; font-size: 0.77rem; font-weight: 600;
|
||||
cursor: pointer; border-radius: 999px;
|
||||
border: 1px solid var(--primary); background: none; color: var(--primary);
|
||||
}
|
||||
.mdl-offered button:disabled { opacity: 0.5; cursor: default; }
|
||||
.mdl-have { font-size: 0.75rem; color: var(--text-subtle); }
|
||||
.mdl-badge {
|
||||
font-size: 0.66rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase;
|
||||
padding: 2px 7px; border-radius: 999px;
|
||||
color: var(--primary); background: var(--option-sel-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.mdl-jobs > li { align-items: flex-start; }
|
||||
.mdl-pick { width: 100%; }
|
||||
.mdl-pick select { flex: 1; min-width: 0; }
|
||||
}
|
||||
|
||||
/* ── Semantic search ──────────────────────────────────────────────── */
|
||||
.mdl-embed {
|
||||
padding: 13px 14px;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.mdl-embed-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.mdl-embed-row input, .mdl-embed-row select {
|
||||
flex: 1; min-width: 180px; padding: 7px 10px;
|
||||
/* 16px on touch: iOS zooms in on anything smaller and never zooms back. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .mdl-embed-row input, .mdl-embed-row select { font-size: 0.84rem; } }
|
||||
.mdl-embed-note { margin: 10px 0 0; font-size: 0.78rem; color: var(--text-muted); }
|
||||
357
frontend/src/components/ModelsAdmin.jsx
Normal file
357
frontend/src/components/ModelsAdmin.jsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
import Dialog from './Dialog'
|
||||
import './ModelsAdmin.css'
|
||||
|
||||
//: The jobs, named by what they do rather than by their field name.
|
||||
const JOBS = [
|
||||
{ key: 'teach', label: 'Tutor', hint: 'The assistant a learner talks to.' },
|
||||
{ key: 'extraction', label: 'Extraction', hint: 'Pulling questions out of a PDF.' },
|
||||
{ key: 'article', label: 'Articles', hint: 'Drafting and refining reading.' },
|
||||
{ key: 'flashcard', label: 'Cards', hint: 'Generating flashcard decks.' },
|
||||
{ key: 'keyword', label: 'Classification', hint: 'Suggesting subjects and tags.' },
|
||||
{ key: 'tts', label: 'Read aloud', hint: 'Turning a question into speech.' },
|
||||
{ key: 'stt', label: 'Transcription', hint: 'Turning speech into text.' },
|
||||
]
|
||||
|
||||
//: Semantic search is a job like the others, but its model is a site setting
|
||||
//: rather than a row — one vector space, not a choice per request. It sits
|
||||
//: here because it is a model, and a section holding one field is not a place.
|
||||
const EMBEDDING = { key: 'embedding', label: 'Semantic search', hint: 'The vectors behind search and recommendations.' }
|
||||
|
||||
const detail = (err, fallback) => {
|
||||
const value = err?.response?.data?.detail
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
const truthy = value => value === true || value === 1
|
||||
|
||||
/**
|
||||
* Which model does each job.
|
||||
*
|
||||
* The old screen made you think in rows: seven cards, each holding model
|
||||
* records with Default, Test, Disable and Remove, so choosing the tutor's
|
||||
* model meant finding its card, finding the row, and pressing Set Default on
|
||||
* it — four buttons per row to express one fact.
|
||||
*
|
||||
* There are two decisions here, and they are now two places. Which models this
|
||||
* site may use at all is an allow-list you set once. Which one does a
|
||||
* particular job is a single choice, made where the job is named. A job with
|
||||
* only one model offers no choice at all, because there is none to make.
|
||||
*/
|
||||
export default function ModelsAdmin() {
|
||||
const { dialogProps, openConfirm } = useDialog()
|
||||
const [models, setModels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [results, setResults] = useState({})
|
||||
const [managing, setManaging] = useState(false)
|
||||
|
||||
// The proxy's catalogue, asked for only when the allow-list is opened.
|
||||
const [offered, setOffered] = useState([])
|
||||
const [finding, setFinding] = useState(false)
|
||||
const [findError, setFindError] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
const [allowFor, setAllowFor] = useState('teach')
|
||||
|
||||
// Embeddings: one model for the whole site.
|
||||
const [embedding, setEmbedding] = useState('')
|
||||
const [embeddingSaved, setEmbeddingSaved] = useState('')
|
||||
const [embedOffered, setEmbedOffered] = useState([])
|
||||
const [embedBusy, setEmbedBusy] = useState('')
|
||||
const [embedResult, setEmbedResult] = useState(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
Promise.all([api.get('/admin/models'), api.get('/admin/settings')])
|
||||
.then(([list, settings]) => {
|
||||
setModels(list.data || [])
|
||||
const current = settings.data?.embedding_model || ''
|
||||
setEmbedding(current)
|
||||
setEmbeddingSaved(current)
|
||||
})
|
||||
.catch(() => setError('Could not load the models'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const act = async (key, run, failure, message) => {
|
||||
setBusy(key); setError(''); setNotice('')
|
||||
try { await run(); if (message) setNotice(message) }
|
||||
catch (err) { setError(detail(err, failure)) }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
/** Assignment: exactly one model does a job, so the rest of that job lose it. */
|
||||
const assign = (job, modelId) => act(`job:${job.key}`,
|
||||
async () => {
|
||||
await api.put(`/admin/models/${modelId}`, { is_default: true, is_active: true })
|
||||
setModels(prev => prev.map(row => (row.task === job.key
|
||||
? { ...row, is_default: row.id === Number(modelId), is_active: row.id === Number(modelId) ? true : row.is_active }
|
||||
: row)))
|
||||
},
|
||||
'Could not change that')
|
||||
|
||||
const test = async (model) => {
|
||||
setBusy(`test:${model.id}`)
|
||||
setResults(r => ({ ...r, [model.id]: null }))
|
||||
try {
|
||||
const res = await api.post(`/admin/models/${model.id}/test`)
|
||||
setResults(r => ({ ...r, [model.id]: { ok: true, message: res.data.message || 'Answered' } }))
|
||||
} catch (err) {
|
||||
setResults(r => ({ ...r, [model.id]: { ok: false, message: detail(err, 'No answer') } }))
|
||||
} finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const offer = async () => {
|
||||
setFinding(true); setFindError(''); setFilter('')
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', {})
|
||||
setOffered(res.data.models || [])
|
||||
} catch (err) { setFindError(detail(err, 'Could not reach the proxy')) }
|
||||
finally { setFinding(false) }
|
||||
}
|
||||
|
||||
const allow = (modelId) => act(`allow:${modelId}`,
|
||||
async () => {
|
||||
const res = await api.post('/admin/models',
|
||||
{ name: modelId, model_id: modelId, task: allowFor, is_active: true, is_default: false })
|
||||
if (res.data?.id) setModels(prev => [...prev, res.data])
|
||||
else load()
|
||||
},
|
||||
'Could not allow that model',
|
||||
`${modelId} is available for ${JOBS.find(j => j.key === allowFor)?.label}.`)
|
||||
|
||||
const disallow = async (model) => {
|
||||
const job = JOBS.find(j => j.key === model.task)
|
||||
const ok = await openConfirm(
|
||||
truthy(model.is_default)
|
||||
? `${model.name} is what does ${job?.label || model.task}. Removing it leaves that job with no model until you pick another.`
|
||||
: `Stop using ${model.name} for ${job?.label || model.task}?`,
|
||||
{ title: 'Remove this model', confirmLabel: 'Remove', danger: true })
|
||||
if (!ok) return
|
||||
await act(`allow:${model.id}`,
|
||||
async () => {
|
||||
await api.delete(`/admin/models/${model.id}`)
|
||||
setModels(prev => prev.filter(row => row.id !== model.id))
|
||||
},
|
||||
'Could not remove that model')
|
||||
}
|
||||
|
||||
const saveEmbedding = (modelId) => act('embedding',
|
||||
async () => {
|
||||
await api.put('/admin/settings', { embedding_model: modelId })
|
||||
setEmbedding(modelId)
|
||||
setEmbeddingSaved(modelId)
|
||||
},
|
||||
'Could not save that model',
|
||||
'Saved. Vectors made by the previous model are not comparable — regenerate them.')
|
||||
|
||||
const testEmbedding = async () => {
|
||||
setEmbedBusy('test'); setEmbedResult(null)
|
||||
try {
|
||||
const res = await api.post('/admin/embedding/test')
|
||||
setEmbedResult({ ok: true, message: `${res.data.model} · ${res.data.dimensions} dimensions` })
|
||||
} catch (err) { setEmbedResult({ ok: false, message: detail(err, 'No answer') }) }
|
||||
finally { setEmbedBusy('') }
|
||||
}
|
||||
|
||||
const regenerate = async () => {
|
||||
const ok = await openConfirm(
|
||||
'Re-embed every question with the current model? Search stays usable while it runs.',
|
||||
{ title: 'Regenerate vectors', confirmLabel: 'Start' })
|
||||
if (!ok) return
|
||||
setEmbedBusy('regen'); setError(''); setNotice('')
|
||||
try {
|
||||
await api.post('/admin/embedding/regenerate')
|
||||
setNotice('Regenerating. It runs in the background.')
|
||||
} catch (err) { setError(detail(err, 'Could not start that')) }
|
||||
finally { setEmbedBusy('') }
|
||||
}
|
||||
|
||||
const offerEmbeddings = async () => {
|
||||
setEmbedBusy('find'); setError('')
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
|
||||
setEmbedOffered(res.data.models || [])
|
||||
} catch (err) { setError(detail(err, 'Could not reach the proxy')) }
|
||||
finally { setEmbedBusy('') }
|
||||
}
|
||||
|
||||
const byJob = useMemo(() => {
|
||||
const grouped = Object.fromEntries(JOBS.map(job => [job.key, []]))
|
||||
for (const model of models) (grouped[model.task] ||= []).push(model)
|
||||
return grouped
|
||||
}, [models])
|
||||
|
||||
const alreadyAllowed = useMemo(
|
||||
() => new Set(models.filter(m => m.task === allowFor).map(m => m.model_id)),
|
||||
[models, allowFor])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase()
|
||||
return needle ? offered.filter(id => id.toLowerCase().includes(needle)) : offered
|
||||
}, [offered, filter])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
|
||||
return (
|
||||
<div className="mdl">
|
||||
<Dialog {...dialogProps} />
|
||||
{error && <p className="mdl-error" role="alert">{error}</p>}
|
||||
{notice && <p className="mdl-notice" role="status">{notice}</p>}
|
||||
|
||||
<ul className="mdl-jobs">
|
||||
{JOBS.map(job => {
|
||||
const options = byJob[job.key] || []
|
||||
const chosen = options.find(m => truthy(m.is_default)) || options[0]
|
||||
const result = chosen && results[chosen.id]
|
||||
return (
|
||||
<li key={job.key}>
|
||||
<div className="mdl-job">
|
||||
<strong>{job.label}</strong>
|
||||
<small>{job.hint}</small>
|
||||
</div>
|
||||
<div className="mdl-pick">
|
||||
{options.length === 0 ? (
|
||||
<span className="mdl-none">Falls back to the site default</span>
|
||||
) : options.length === 1 ? (
|
||||
// One model is not a choice, so it is not offered as one.
|
||||
<code>{chosen.name}</code>
|
||||
) : (
|
||||
<select value={chosen?.id ?? ''} disabled={busy === `job:${job.key}`}
|
||||
aria-label={`Model for ${job.label}`}
|
||||
onChange={e => assign(job, e.target.value)}>
|
||||
{options.map(model => (
|
||||
<option key={model.id} value={model.id}>{model.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{chosen && (
|
||||
<button type="button" className="mdl-test" disabled={busy === `test:${chosen.id}`}
|
||||
aria-label={`Test the model for ${job.label}`} onClick={() => test(chosen)}>
|
||||
{busy === `test:${chosen.id}` ? 'Testing…' : 'Test'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{result && (
|
||||
<p className={`mdl-result${result.ok ? ' is-ok' : ' is-bad'}`} role="status">
|
||||
{result.ok ? '✓ ' : '✗ '}{result.message}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<section className="mdl-embed">
|
||||
<div className="mdl-job">
|
||||
<strong>{EMBEDDING.label}</strong>
|
||||
<small>{EMBEDDING.hint}</small>
|
||||
</div>
|
||||
<div className="mdl-embed-row">
|
||||
{embedOffered.length > 0 ? (
|
||||
<select value={embedding} aria-label="Embedding model"
|
||||
onChange={e => setEmbedding(e.target.value)}>
|
||||
<option value="">(not set)</option>
|
||||
{embedOffered.map(id => <option key={id} value={id}>{id}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={embedding} aria-label="Embedding model"
|
||||
placeholder="e.g. ge-gemini-embedding-001"
|
||||
onChange={e => setEmbedding(e.target.value)} />
|
||||
)}
|
||||
<button type="button" className="mdl-test" disabled={embedBusy === 'find'}
|
||||
onClick={offerEmbeddings}>
|
||||
{embedBusy === 'find' ? 'Asking…' : 'List'}
|
||||
</button>
|
||||
<button type="button" className="mdl-test" disabled={busy === 'embedding' || embedding === embeddingSaved}
|
||||
onClick={() => saveEmbedding(embedding)}>Save</button>
|
||||
<button type="button" className="mdl-test" disabled={embedBusy === 'test'}
|
||||
onClick={testEmbedding}>{embedBusy === 'test' ? 'Testing…' : 'Test'}</button>
|
||||
<button type="button" className="mdl-test" disabled={embedBusy === 'regen'}
|
||||
onClick={regenerate}>Regenerate</button>
|
||||
</div>
|
||||
{embedResult && (
|
||||
<p className={`mdl-result${embedResult.ok ? ' is-ok' : ' is-bad'}`} role="status">
|
||||
{embedResult.ok ? '✓ ' : '✗ '}{embedResult.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="mdl-embed-note">
|
||||
{/* Vectors from different models are not comparable, so a change here
|
||||
silently degrades search until everything is re-embedded. */}
|
||||
Changing this makes every existing vector incomparable. Regenerate after saving.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mdl-allow">
|
||||
<button type="button" className="mdl-allow-head" aria-expanded={managing}
|
||||
onClick={() => setManaging(v => !v)}>
|
||||
<span>
|
||||
<strong>Which models this site may use</strong>
|
||||
<small>{models.length} allowed across {JOBS.length} jobs</small>
|
||||
</span>
|
||||
<span aria-hidden="true">{managing ? '⌃' : '⌄'}</span>
|
||||
</button>
|
||||
|
||||
{managing && (
|
||||
<div className="mdl-allow-body">
|
||||
<div className="mdl-allow-bar">
|
||||
<label>
|
||||
<span>For</span>
|
||||
<select value={allowFor} aria-label="Job to allow a model for"
|
||||
onChange={e => setAllowFor(e.target.value)}>
|
||||
{JOBS.map(job => <option key={job.key} value={job.key}>{job.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="btn btn-secondary btn-sm" disabled={finding} onClick={offer}>
|
||||
{finding ? 'Asking the proxy…' : offered.length ? 'Ask again' : 'List what the proxy offers'}
|
||||
</button>
|
||||
</div>
|
||||
{findError && <p className="mdl-error" role="alert">{findError}</p>}
|
||||
|
||||
<ul className="mdl-current">
|
||||
{(byJob[allowFor] || []).map(model => (
|
||||
<li key={model.id}>
|
||||
<code>{model.model_id}</code>
|
||||
{truthy(model.is_default) && <span className="mdl-badge">in use</span>}
|
||||
<button type="button" className="mdl-remove" disabled={busy === `allow:${model.id}`}
|
||||
aria-label={`Remove ${model.name}`} onClick={() => disallow(model)}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
{(byJob[allowFor] || []).length === 0 && (
|
||||
<li className="mdl-empty">Nothing allowed for this job yet.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{offered.length > 0 && (
|
||||
<>
|
||||
<input type="search" value={filter} aria-label="Filter offered models"
|
||||
placeholder={`Filter ${offered.length} models`}
|
||||
onChange={e => setFilter(e.target.value)} />
|
||||
{/* Bounded: the proxy answers with hundreds. */}
|
||||
<ul className="mdl-offered">
|
||||
{visible.map(id => (
|
||||
<li key={id}>
|
||||
<code>{id}</code>
|
||||
{alreadyAllowed.has(id) ? (
|
||||
<span className="mdl-have">allowed</span>
|
||||
) : (
|
||||
<button type="button" disabled={busy === `allow:${id}`}
|
||||
aria-label={`Allow ${id}`} onClick={() => allow(id)}>Allow</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{visible.length === 0 && <li className="mdl-empty">Nothing matches that.</li>}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
109
frontend/src/components/ModelsAdmin.test.jsx
Normal file
109
frontend/src/components/ModelsAdmin.test.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ModelsAdmin from './ModelsAdmin'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
}))
|
||||
vi.mock('../hooks/useDialog', () => ({
|
||||
useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }),
|
||||
}))
|
||||
vi.mock('./Dialog', () => ({ default: () => null }))
|
||||
|
||||
const MODELS = [
|
||||
{ id: 1, name: 'Haiku', model_id: 'claude-haiku-4-5', task: 'teach', is_active: true, is_default: true },
|
||||
{ id: 2, name: 'Sonnet', model_id: 'claude-sonnet-5', task: 'teach', is_active: true, is_default: false },
|
||||
{ id: 3, name: 'Opus', model_id: 'claude-opus-5', task: 'extraction', is_active: true, is_default: true },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: MODELS })
|
||||
api.put.mockResolvedValue({ data: {} })
|
||||
api.delete.mockResolvedValue({})
|
||||
api.post.mockImplementation(url => {
|
||||
if (url === '/admin/litellm/models') {
|
||||
return Promise.resolve({ data: { models: ['claude-haiku-4-5', 'gpt-5', 'gemini-3'] } })
|
||||
}
|
||||
if (url === '/admin/models') {
|
||||
return Promise.resolve({ data: { id: 9, name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false } })
|
||||
}
|
||||
return Promise.resolve({ data: { message: 'Answered' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('model selection', () => {
|
||||
it('asks one question per job rather than showing rows of buttons', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
// The old screen needed you to find a card, find a row, and press Set
|
||||
// Default on it — four buttons per row to express one fact.
|
||||
expect(await screen.findByRole('combobox', { name: 'Model for Tutor' })).toHaveValue('1')
|
||||
expect(screen.queryByRole('button', { name: 'Set Default' })).toBeNull()
|
||||
})
|
||||
|
||||
it('changes a job by choosing, and moves the choice off the old model', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
const pick = await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
api.get.mockClear()
|
||||
await userEvent.selectOptions(pick, '2')
|
||||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/models/2',
|
||||
{ is_default: true, is_active: true }))
|
||||
expect(pick).toHaveValue('2')
|
||||
// Updated in place, not by refetching the section.
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers no choice for a job with one model, because there is none', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
expect(screen.queryByRole('combobox', { name: 'Model for Extraction' })).toBeNull()
|
||||
expect(screen.getByText('Opus')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when a job has nothing configured instead of showing an empty box', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
expect(screen.getAllByText('Falls back to the site default').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('tests the model a job actually uses', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tutor' }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/1/test'))
|
||||
expect(await screen.findByText(/Answered/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the allow-list folded away, since it is set once', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
expect(screen.queryByRole('button', { name: /List what the proxy offers/ })).toBeNull()
|
||||
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
||||
expect(screen.getByRole('button', { name: /List what the proxy offers/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('will not offer to allow a model that is already allowed', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
|
||||
const offered = await screen.findByRole('button', { name: 'Allow gpt-5' })
|
||||
expect(offered).toBeInTheDocument()
|
||||
// Already allowed for Tutor, so there is nothing to press.
|
||||
expect(screen.queryByRole('button', { name: 'Allow claude-haiku-4-5' })).toBeNull()
|
||||
})
|
||||
|
||||
it('adds an allowed model without reloading the section', async () => {
|
||||
render(<ModelsAdmin />)
|
||||
await screen.findByRole('combobox', { name: 'Model for Tutor' })
|
||||
await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ }))
|
||||
api.get.mockClear()
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Allow gpt-5' }))
|
||||
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models', {
|
||||
name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false,
|
||||
}))
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
67
frontend/src/components/PeopleAdmin.css
Normal file
67
frontend/src/components/PeopleAdmin.css
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
.ppl { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ppl-error { margin: 0; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||
.ppl-notice { margin: 0; font-size: 0.85rem; color: var(--right-fg, #15803d); }
|
||||
|
||||
.ppl-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.ppl-bar input[type="search"] {
|
||||
flex: 1; min-width: 180px; padding: 9px 12px;
|
||||
/* 16px on touch: iOS zooms in on anything smaller and never zooms back. */
|
||||
font-size: 16px; font-family: inherit;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
@media (min-width: 700px) { .ppl-bar input[type="search"] { font-size: 0.88rem; } }
|
||||
|
||||
.ppl-new {
|
||||
padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg);
|
||||
}
|
||||
.ppl-new small { display: block; margin-top: 4px; font-size: 0.78rem; color: var(--text-muted); }
|
||||
.ppl-new-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* Bounded, so two hundred accounts do not push the rest of settings away. */
|
||||
.ppl-list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
max-height: 460px; overflow-y: auto;
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.ppl-list > li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 12px; flex-wrap: wrap;
|
||||
padding: 11px 14px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ppl-list > li:last-child { border-bottom: 0; }
|
||||
.ppl-list > li.is-busy { opacity: 0.55; }
|
||||
.ppl-who { min-width: 0; }
|
||||
.ppl-who strong { display: flex; align-items: center; gap: 7px; font-size: 0.92rem; font-weight: 650; }
|
||||
.ppl-who small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); }
|
||||
.ppl-you {
|
||||
font-size: 0.62rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
color: var(--primary); background: var(--option-sel-bg);
|
||||
}
|
||||
|
||||
.ppl-controls { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.ppl-role select {
|
||||
padding: 5px 9px; font: inherit; font-size: 0.82rem;
|
||||
border: 1px solid var(--border); border-radius: 7px;
|
||||
background: var(--input-bg); color: var(--text);
|
||||
}
|
||||
.ppl-role select:disabled { opacity: 0.6; }
|
||||
|
||||
/* Says what is true now, not what pressing it does — the title carries that. */
|
||||
.ppl-limit, .ppl-remove {
|
||||
padding: 5px 11px; font: inherit; font-size: 0.78rem; font-weight: 600;
|
||||
cursor: pointer; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted);
|
||||
}
|
||||
.ppl-limit.is-on { border-color: var(--primary); color: var(--primary); }
|
||||
.ppl-remove { color: var(--wrong-fg); border-color: var(--wrong-bd); }
|
||||
.ppl-remove:hover:not(:disabled) { background: var(--wrong-bg); }
|
||||
.ppl-limit:disabled, .ppl-remove:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.ppl-empty { padding: 20px 14px; font-size: 0.86rem; color: var(--text-muted); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.ppl-list > li { align-items: flex-start; }
|
||||
.ppl-controls { width: 100%; }
|
||||
}
|
||||
215
frontend/src/components/PeopleAdmin.jsx
Normal file
215
frontend/src/components/PeopleAdmin.jsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useDialog } from '../hooks/useDialog'
|
||||
import Dialog from './Dialog'
|
||||
import './PeopleAdmin.css'
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'user', label: 'User', hint: 'Studies. Can make their own tests.' },
|
||||
{ value: 'moderator', label: 'Moderator', hint: 'Edits content and runs courses.' },
|
||||
{ value: 'admin', label: 'Admin', hint: 'Everything, including this page.' },
|
||||
]
|
||||
|
||||
const detail = (err, fallback) => {
|
||||
const value = err?.response?.data?.detail
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
const joined = value => (value
|
||||
? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
: '')
|
||||
|
||||
/**
|
||||
* Everyone with an account.
|
||||
*
|
||||
* This was the admin dashboard's users tab rendered inside a settings panel —
|
||||
* its own cards inside our card, in an older visual language, and refetching
|
||||
* users, models and settings together after every change so the whole section
|
||||
* blinked and reads as a page reload.
|
||||
*
|
||||
* It is a settings section in its own right now, and a change updates the one
|
||||
* row it touched from what the server sent back. Nothing else moves.
|
||||
*/
|
||||
export default function PeopleAdmin() {
|
||||
const { user: me } = useAuth()
|
||||
const { dialogProps, openConfirm } = useDialog()
|
||||
const [people, setPeople] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [busyId, setBusyId] = useState(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [draft, setDraft] = useState({ name: '', email: '', password: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.get('/admin/users')
|
||||
.then(res => setPeople(res.data || []))
|
||||
.catch(() => setError('Could not load the people list'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
/** Replace one row from what the server said, rather than refetching the lot. */
|
||||
const patchRow = (id, fields) =>
|
||||
setPeople(prev => prev.map(row => (row.id === id ? { ...row, ...fields } : row)))
|
||||
|
||||
const act = async (id, run, failure, message) => {
|
||||
setBusyId(id); setError(''); setNotice('')
|
||||
try {
|
||||
const result = await run()
|
||||
if (message) setNotice(message)
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(detail(err, failure))
|
||||
return null
|
||||
} finally { setBusyId(null) }
|
||||
}
|
||||
|
||||
const setRole = (row, role) => act(row.id,
|
||||
async () => {
|
||||
await api.put(`/admin/users/${row.id}/role`, { role })
|
||||
patchRow(row.id, { role })
|
||||
},
|
||||
'Could not change that role',
|
||||
`${row.name || row.email} is now ${role}.`)
|
||||
|
||||
const setThrottle = (row) => act(row.id,
|
||||
async () => {
|
||||
const next = !row.is_unthrottled
|
||||
await api.put(`/admin/users/${row.id}/unthrottle`, { unthrottled: next })
|
||||
patchRow(row.id, { is_unthrottled: next })
|
||||
},
|
||||
'Could not change that limit')
|
||||
|
||||
const remove = async (row) => {
|
||||
const ok = await openConfirm(
|
||||
`Delete ${row.name || row.email}? Their attempts, saved questions, notes and settings go with them.`,
|
||||
{ title: 'Delete this account', confirmLabel: 'Delete', danger: true })
|
||||
if (!ok) return
|
||||
await act(row.id,
|
||||
async () => {
|
||||
await api.delete(`/admin/users/${row.id}`)
|
||||
setPeople(prev => prev.filter(p => p.id !== row.id))
|
||||
},
|
||||
'Could not delete that account',
|
||||
`${row.name || row.email} removed.`)
|
||||
}
|
||||
|
||||
const create = async (e) => {
|
||||
e.preventDefault()
|
||||
setCreating(true); setError(''); setNotice('')
|
||||
try {
|
||||
const res = await api.post('/admin/users', draft)
|
||||
// Added where it belongs rather than by reloading everything.
|
||||
if (res.data?.id) setPeople(prev => [...prev, res.data])
|
||||
else load()
|
||||
setNotice(`${draft.email} created.`)
|
||||
setDraft({ name: '', email: '', password: '' })
|
||||
setAdding(false)
|
||||
} catch (err) { setError(detail(err, 'Could not create that account')) }
|
||||
finally { setCreating(false) }
|
||||
}
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
if (!needle) return people
|
||||
return people.filter(row =>
|
||||
`${row.name || ''} ${row.email || ''} ${row.role || ''}`.toLowerCase().includes(needle))
|
||||
}, [people, query])
|
||||
|
||||
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||
|
||||
return (
|
||||
<div className="ppl">
|
||||
<Dialog {...dialogProps} />
|
||||
{error && <p className="ppl-error" role="alert">{error}</p>}
|
||||
{notice && <p className="ppl-notice" role="status">{notice}</p>}
|
||||
|
||||
<div className="ppl-bar">
|
||||
<input type="search" value={query} aria-label="Search people"
|
||||
placeholder={`Search ${people.length} ${people.length === 1 ? 'person' : 'people'}`}
|
||||
onChange={e => setQuery(e.target.value)} />
|
||||
{!adding && (
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={() => setAdding(true)}>
|
||||
+ Add someone
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form className="ppl-new" onSubmit={create}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ppl-name">Name</label>
|
||||
<input id="ppl-name" value={draft.name} required
|
||||
onChange={e => setDraft(d => ({ ...d, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ppl-email">Email</label>
|
||||
<input id="ppl-email" type="email" value={draft.email} required
|
||||
onChange={e => setDraft(d => ({ ...d, email: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="ppl-pass">Password</label>
|
||||
<input id="ppl-pass" type="password" value={draft.password} required minLength={8}
|
||||
autoComplete="new-password"
|
||||
onChange={e => setDraft(d => ({ ...d, password: e.target.value }))} />
|
||||
<small>At least 8 characters. They can change it once they are in.</small>
|
||||
</div>
|
||||
<div className="ppl-new-actions">
|
||||
<button className="btn btn-primary" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" type="button" onClick={() => setAdding(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Bounded and scrolled, so a long list does not push everything else
|
||||
off the page. */}
|
||||
<ul className="ppl-list">
|
||||
{shown.map(row => (
|
||||
<li key={row.id} className={busyId === row.id ? 'is-busy' : undefined}>
|
||||
<div className="ppl-who">
|
||||
<strong>
|
||||
{row.name || row.email}
|
||||
{row.id === me?.id && <span className="ppl-you">you</span>}
|
||||
</strong>
|
||||
<small>{row.email} · joined {joined(row.created_at)}</small>
|
||||
</div>
|
||||
<div className="ppl-controls">
|
||||
<label className="ppl-role">
|
||||
<span className="sr-only">Role for {row.name || row.email}</span>
|
||||
<select value={row.role} disabled={busyId === row.id || row.id === me?.id}
|
||||
onChange={e => setRole(row, e.target.value)}
|
||||
title={row.id === me?.id ? 'You cannot change your own role' : undefined}>
|
||||
{ROLES.map(role => (
|
||||
<option key={role.value} value={role.value}>{role.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" disabled={busyId === row.id}
|
||||
className={`ppl-limit${row.is_unthrottled ? ' is-on' : ''}`}
|
||||
title={row.is_unthrottled
|
||||
? 'No rate limit applies to this account. Click to restore it.'
|
||||
: 'Normal rate limits apply. Click to lift them.'}
|
||||
onClick={() => setThrottle(row)}>
|
||||
{row.is_unthrottled ? 'Unlimited' : 'Limited'}
|
||||
</button>
|
||||
{row.id !== me?.id && (
|
||||
<button type="button" className="ppl-remove" disabled={busyId === row.id}
|
||||
aria-label={`Delete ${row.name || row.email}`} onClick={() => remove(row)}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{shown.length === 0 && <li className="ppl-empty">Nobody matches that.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
frontend/src/components/PeopleAdmin.test.jsx
Normal file
100
frontend/src/components/PeopleAdmin.test.jsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PeopleAdmin from './PeopleAdmin'
|
||||
import api from '../api/client'
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
}))
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({ user: { id: 1, name: 'Admin', role: 'admin' } }),
|
||||
}))
|
||||
vi.mock('../hooks/useDialog', () => ({
|
||||
useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }),
|
||||
}))
|
||||
vi.mock('./Dialog', () => ({ default: () => null }))
|
||||
|
||||
const PEOPLE = [
|
||||
{ id: 1, name: 'Admin', email: 'admin@example.test', role: 'admin', created_at: '2026-01-01', is_unthrottled: true },
|
||||
{ id: 2, name: 'Maria', email: 'maria@example.test', role: 'user', created_at: '2026-04-27', is_unthrottled: false },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.get.mockResolvedValue({ data: PEOPLE })
|
||||
api.put.mockResolvedValue({ data: {} })
|
||||
api.delete.mockResolvedValue({})
|
||||
api.post.mockResolvedValue({ data: { id: 3, name: 'New', email: 'new@example.test', role: 'user', created_at: '2026-09-11' } })
|
||||
})
|
||||
|
||||
const roleFor = name => screen.getByRole('combobox', { name: `Role for ${name}` })
|
||||
|
||||
describe('people', () => {
|
||||
it('changes a role without refetching the whole section', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
api.get.mockClear()
|
||||
await userEvent.selectOptions(roleFor('Maria'), 'moderator')
|
||||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/users/2/role', { role: 'moderator' }))
|
||||
// The old page called loadData() after every change, refetching users,
|
||||
// models and settings together — which is what read as a page reload.
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
expect(roleFor('Maria')).toHaveValue('moderator')
|
||||
})
|
||||
|
||||
it('will not let you change your own role', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
expect(roleFor('Admin')).toBeDisabled()
|
||||
expect(screen.getByText('you')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no way to delete yourself', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
expect(screen.queryByRole('button', { name: 'Delete Admin' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Delete Maria' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('takes a deleted person off the list without a refetch', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
api.get.mockClear()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete Maria' }))
|
||||
await waitFor(() => expect(screen.queryByText('Maria')).toBeNull())
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adds a new account where it belongs rather than reloading', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
api.get.mockClear()
|
||||
await userEvent.click(screen.getByRole('button', { name: '+ Add someone' }))
|
||||
await userEvent.type(screen.getByLabelText('Name'), 'New')
|
||||
await userEvent.type(screen.getByLabelText('Email'), 'new@example.test')
|
||||
await userEvent.type(screen.getByLabelText('Password'), 'longenough1')
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Create account' }))
|
||||
expect(await screen.findByText('New')).toBeInTheDocument()
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('searches the list it already has', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
await userEvent.type(screen.getByLabelText('Search people'), 'maria')
|
||||
// "Admin" is also a role option, so ask about the row rather than the word.
|
||||
expect(screen.queryByText('admin@example.test')).toBeNull()
|
||||
expect(screen.getByText(/maria@example.test/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says what the rate limit is now, not what the button does', async () => {
|
||||
render(<PeopleAdmin />)
|
||||
await screen.findByText('Maria')
|
||||
expect(screen.getByRole('button', { name: 'Limited' })).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Limited' }))
|
||||
await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/users/2/unthrottle',
|
||||
{ unthrottled: true }))
|
||||
expect(await screen.findAllByRole('button', { name: 'Unlimited' })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,715 +0,0 @@
|
|||
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', 'stt', 'teach', 'keyword', 'flashcard', 'article']
|
||||
|
||||
/**
|
||||
* Administration.
|
||||
*
|
||||
* Rendered inside Settings rather than as a page of its own: there should be
|
||||
* one place where the site is configured, not a settings page that links to a
|
||||
* second settings page. `section` names which part to show and comes from the
|
||||
* Settings nav. /admin now redirects into Settings, so the tab bar below is
|
||||
* only reached by rendering this without `embedded` — kept because the tabs
|
||||
* are what the sections are named by, and losing them would lose that.
|
||||
*/
|
||||
export default function AdminPage({ section, embedded = false }) {
|
||||
const { user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { dialogProps, openConfirm } = useDialog()
|
||||
const [ownTab, setOwnTab] = useState('models')
|
||||
const tab = section || ownTab
|
||||
const setTab = setOwnTab
|
||||
const [users, setUsers] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
|
||||
|
||||
// LiteLLM search state
|
||||
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('litellm')
|
||||
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', 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', {})
|
||||
setSearchResults(res.data.models)
|
||||
} catch (err) {
|
||||
setSearchError(err.response?.data?.detail || 'Failed to query models')
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const searchEmbeddingModels = async () => {
|
||||
setEmbedSearchError('')
|
||||
setEmbedSearchResults([])
|
||||
setEmbedSearchFilter('')
|
||||
setEmbedSearchLoading(true)
|
||||
try {
|
||||
const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
|
||||
setEmbedSearchResults(res.data.models || [])
|
||||
} 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,
|
||||
})
|
||||
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
|
||||
|
||||
const adminTabs = [
|
||||
{ id: 'models', label: 'AI Models' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'settings', label: 'More' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog {...dialogProps} />
|
||||
{/* Inside Settings the section is already named by the nav, and a second
|
||||
row of tabs would be a second way to be somewhere. */}
|
||||
{!embedded && (
|
||||
<div className="card">
|
||||
<h2>Admin Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
{adminTabs.map(({ id, label }) => (
|
||||
<button key={id} className={`btn ${tab === id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(id)}>
|
||||
{label}
|
||||
</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 the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
|
||||
</p>
|
||||
<div className="grid-2">
|
||||
<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 === 'stt' ? '— Speech-to-text models' :
|
||||
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
||||
task === 'keyword' ? '— Keyword and topic classification' :
|
||||
'— Flashcard generation'}
|
||||
</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="litellm">LiteLLM Local</option>
|
||||
</select>
|
||||
<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" 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>Semantic search</h2>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,19 +8,33 @@
|
|||
.set-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
align-items: stretch;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 48px;
|
||||
/* Fill the window rather than ending wherever the content happens to stop.
|
||||
On a tall screen a short section left two thirds of the page empty and a
|
||||
long one ran off the bottom; the panel itself scrolls now, so the nav and
|
||||
the heading stay put either way. */
|
||||
min-height: calc(100dvh - 150px);
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* The panel column: bounded by the grid row, scrolling inside itself. */
|
||||
.set-body { min-height: 0; overflow-y: auto; padding-right: 4px; }
|
||||
.set-body > .set-panel { margin-bottom: 16px; }
|
||||
.set-body > .set-panel:last-child { margin-bottom: 0; }
|
||||
|
||||
.set-head { grid-column: 1 / -1; margin-bottom: 4px; }
|
||||
.set-head h1 { margin: 0 0 4px; font-size: 1.4rem; font-weight: 700; }
|
||||
.set-head p { margin: 0; font-size: 0.86rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Section list ─────────────────────────────────────────────────── */
|
||||
.set-nav { position: sticky; top: 76px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.set-nav {
|
||||
position: sticky; top: 76px; align-self: start;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.set-nav button {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; min-height: 44px; /* touch target */
|
||||
|
|
@ -105,6 +119,8 @@
|
|||
changing a password. */
|
||||
@media (max-width: 820px) {
|
||||
.set-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.set-layout { min-height: 0; grid-template-rows: none; }
|
||||
.set-body { overflow: visible; padding-right: 0; }
|
||||
.set-nav {
|
||||
position: static; flex-direction: row; gap: 6px;
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
|
|
@ -118,23 +134,3 @@
|
|||
.set-nav-group { display: none; }
|
||||
.set-nav-icon { display: none; }
|
||||
}
|
||||
|
||||
/* ── Administration rendered in place ─────────────────────────────────
|
||||
The admin sections were written as their own page and bring their own
|
||||
cards. Inside a settings panel that would be a box in a box, so the
|
||||
outermost layer of chrome is taken off and the padding comes from here. */
|
||||
.set-admin > div > .card,
|
||||
.set-admin > div > div > .card {
|
||||
border: 0; border-radius: 0; padding: 0; margin: 0 0 18px; background: none;
|
||||
}
|
||||
.set-admin > div > .card:last-child,
|
||||
.set-admin > div > div > .card:last-child { margin-bottom: 0; }
|
||||
.set-admin h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.7rem; font-weight: 700; letter-spacing: 0.07em;
|
||||
text-transform: uppercase; color: var(--text-subtle);
|
||||
}
|
||||
.set-admin h3 { font-size: 0.95rem; }
|
||||
.set-admin table { width: 100%; }
|
||||
/* Wide tables scroll inside themselves rather than widening the page. */
|
||||
.set-admin .table-wrap, .set-admin .admin-table-wrap { overflow-x: auto; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, Suspense, useState, useEffect } from 'react'
|
||||
import { Fragment, useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
|
|
@ -6,11 +6,8 @@ import api from '../api/client'
|
|||
import ExamSwitcher from '../components/ExamSwitcher'
|
||||
import SitePolicy from '../components/SitePolicy'
|
||||
import ExamAdmin from '../components/ExamAdmin'
|
||||
import lazyPage from '../utils/lazyPage'
|
||||
|
||||
// Only an administrator ever renders this, and it is the largest thing on the
|
||||
// page — so it is not in everyone else's download.
|
||||
const AdminPage = lazyPage(() => import('./AdminPage'))
|
||||
import PeopleAdmin from '../components/PeopleAdmin'
|
||||
import ModelsAdmin from '../components/ModelsAdmin'
|
||||
import './SettingsPage.css'
|
||||
|
||||
function Section({ title, description, children }) {
|
||||
|
|
@ -247,19 +244,6 @@ function ToolsSection() {
|
|||
)
|
||||
}
|
||||
|
||||
/** One of the admin dashboard's sections, shown in place rather than linked to. */
|
||||
function AdminSection({ title, description, section }) {
|
||||
return (
|
||||
<Section title={title} description={description}>
|
||||
<div className="set-admin">
|
||||
<Suspense fallback={<div className="loading"><div className="spinner" /></div>}>
|
||||
<AdminPage embedded section={section} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const DOCS_PREVIEW = 5
|
||||
|
||||
function DocumentsSection() {
|
||||
|
|
@ -446,14 +430,17 @@ export default function SettingsPage() {
|
|||
</Section>
|
||||
) },
|
||||
{ key: 'people', group: 'The site', icon: '👥', label: 'People',
|
||||
render: () => <AdminSection section="users" title="People"
|
||||
description="Everyone with an account, and what each of them may do." /> },
|
||||
render: () => (
|
||||
<Section title="People" description="Everyone with an account, and what each of them may do.">
|
||||
<PeopleAdmin />
|
||||
</Section>
|
||||
) },
|
||||
{ key: 'models', group: 'The site', icon: '🧠', label: 'AI models',
|
||||
render: () => <AdminSection section="models" title="AI models"
|
||||
description="Which model answers which kind of request, and what happens when one is unavailable." /> },
|
||||
{ key: 'search', group: 'The site', icon: '🔎', label: 'Search',
|
||||
render: () => <AdminSection section="settings" title="Search"
|
||||
description="The embedding model behind semantic search." /> },
|
||||
render: () => (
|
||||
<Section title="AI models" description="Which model does each job.">
|
||||
<ModelsAdmin />
|
||||
</Section>
|
||||
) },
|
||||
] : []),
|
||||
]
|
||||
|
||||
|
|
@ -488,7 +475,9 @@ export default function SettingsPage() {
|
|||
))}
|
||||
</nav>
|
||||
|
||||
<div>{active.render()}</div>
|
||||
{/* Scrolls inside itself so the page does not end halfway down a tall
|
||||
screen, and a long section does not run off the bottom. */}
|
||||
<div className="set-body">{active.render()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ describe('settings', () => {
|
|||
await screen.findByRole('heading', { name: 'Account' })
|
||||
// The old page offered a grid of links, one of which went to /admin and
|
||||
// its own row of tabs. These are sections of this page now.
|
||||
for (const label of ['People', 'AI models', 'Search']) {
|
||||
// Search held one field — the embedding model — which is a model, so it
|
||||
// moved in with the models rather than being a section of its own.
|
||||
for (const label of ['People', 'AI models', 'Exams']) {
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('link', { name: /Admin dashboard/ })).toBeNull()
|
||||
|
|
|
|||
Loading…
Reference in a new issue