diff --git a/frontend/src/components/ModelsAdmin.css b/frontend/src/components/ModelsAdmin.css
new file mode 100644
index 0000000..0692cf1
--- /dev/null
+++ b/frontend/src/components/ModelsAdmin.css
@@ -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); }
diff --git a/frontend/src/components/ModelsAdmin.jsx b/frontend/src/components/ModelsAdmin.jsx
new file mode 100644
index 0000000..badc3c8
--- /dev/null
+++ b/frontend/src/components/ModelsAdmin.jsx
@@ -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
+
+ return (
+
+
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+
+ {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 (
+
+
+ {job.label}
+ {job.hint}
+
+
+ {options.length === 0 ? (
+ Falls back to the site default
+ ) : options.length === 1 ? (
+ // One model is not a choice, so it is not offered as one.
+ {chosen.name}
+ ) : (
+ assign(job, e.target.value)}>
+ {options.map(model => (
+ {model.name}
+ ))}
+
+ )}
+ {chosen && (
+ test(chosen)}>
+ {busy === `test:${chosen.id}` ? 'Testing…' : 'Test'}
+
+ )}
+
+ {result && (
+
+ {result.ok ? '✓ ' : '✗ '}{result.message}
+
+ )}
+
+ )
+ })}
+
+
+
+
+ {EMBEDDING.label}
+ {EMBEDDING.hint}
+
+
+ {embedOffered.length > 0 ? (
+ setEmbedding(e.target.value)}>
+ (not set)
+ {embedOffered.map(id => {id} )}
+
+ ) : (
+ setEmbedding(e.target.value)} />
+ )}
+
+ {embedBusy === 'find' ? 'Asking…' : 'List'}
+
+ saveEmbedding(embedding)}>Save
+ {embedBusy === 'test' ? 'Testing…' : 'Test'}
+ Regenerate
+
+ {embedResult && (
+
+ {embedResult.ok ? '✓ ' : '✗ '}{embedResult.message}
+
+ )}
+
+ {/* 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.
+
+
+
+
+ setManaging(v => !v)}>
+
+ Which models this site may use
+ {models.length} allowed across {JOBS.length} jobs
+
+ {managing ? '⌃' : '⌄'}
+
+
+ {managing && (
+
+
+
+ For
+ setAllowFor(e.target.value)}>
+ {JOBS.map(job => {job.label} )}
+
+
+
+ {finding ? 'Asking the proxy…' : offered.length ? 'Ask again' : 'List what the proxy offers'}
+
+
+ {findError &&
{findError}
}
+
+
+ {(byJob[allowFor] || []).map(model => (
+
+ {model.model_id}
+ {truthy(model.is_default) && in use }
+ disallow(model)}>Remove
+
+ ))}
+ {(byJob[allowFor] || []).length === 0 && (
+ Nothing allowed for this job yet.
+ )}
+
+
+ {offered.length > 0 && (
+ <>
+
setFilter(e.target.value)} />
+ {/* Bounded: the proxy answers with hundreds. */}
+
+ {visible.map(id => (
+
+ {id}
+ {alreadyAllowed.has(id) ? (
+ allowed
+ ) : (
+ allow(id)}>Allow
+ )}
+
+ ))}
+ {visible.length === 0 && Nothing matches that. }
+
+ >
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/ModelsAdmin.test.jsx b/frontend/src/components/ModelsAdmin.test.jsx
new file mode 100644
index 0000000..d45aa4c
--- /dev/null
+++ b/frontend/src/components/ModelsAdmin.test.jsx
@@ -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( )
+ // 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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()
+ })
+})
diff --git a/frontend/src/components/PeopleAdmin.css b/frontend/src/components/PeopleAdmin.css
new file mode 100644
index 0000000..ecc00f7
--- /dev/null
+++ b/frontend/src/components/PeopleAdmin.css
@@ -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%; }
+}
diff --git a/frontend/src/components/PeopleAdmin.jsx b/frontend/src/components/PeopleAdmin.jsx
new file mode 100644
index 0000000..edd361b
--- /dev/null
+++ b/frontend/src/components/PeopleAdmin.jsx
@@ -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
+
+ return (
+
+
+ {error &&
{error}
}
+ {notice &&
{notice}
}
+
+
+ setQuery(e.target.value)} />
+ {!adding && (
+ setAdding(true)}>
+ + Add someone
+
+ )}
+
+
+ {adding && (
+
+ )}
+
+ {/* Bounded and scrolled, so a long list does not push everything else
+ off the page. */}
+
+ {shown.map(row => (
+
+
+
+ {row.name || row.email}
+ {row.id === me?.id && you }
+
+ {row.email} · joined {joined(row.created_at)}
+
+
+
+ Role for {row.name || row.email}
+ setRole(row, e.target.value)}
+ title={row.id === me?.id ? 'You cannot change your own role' : undefined}>
+ {ROLES.map(role => (
+ {role.label}
+ ))}
+
+
+ setThrottle(row)}>
+ {row.is_unthrottled ? 'Unlimited' : 'Limited'}
+
+ {row.id !== me?.id && (
+ remove(row)}>
+ Delete
+
+ )}
+
+
+ ))}
+ {shown.length === 0 && Nobody matches that. }
+
+
+ )
+}
diff --git a/frontend/src/components/PeopleAdmin.test.jsx b/frontend/src/components/PeopleAdmin.test.jsx
new file mode 100644
index 0000000..b74017d
--- /dev/null
+++ b/frontend/src/components/PeopleAdmin.test.jsx
@@ -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( )
+ 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( )
+ await screen.findByText('Maria')
+ expect(roleFor('Admin')).toBeDisabled()
+ expect(screen.getByText('you')).toBeInTheDocument()
+ })
+
+ it('offers no way to delete yourself', async () => {
+ render( )
+ 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( )
+ 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( )
+ 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( )
+ 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( )
+ 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)
+ })
+})
diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx
deleted file mode 100644
index 5ed2215..0000000
--- a/frontend/src/pages/AdminPage.jsx
+++ /dev/null
@@ -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
-
- 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 (
-
-
- {/* 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 && (
-
-
Admin Dashboard
-
- {adminTabs.map(({ id, label }) => (
- setTab(id)}>
- {label}
-
- ))}
-
-
- )}
-
- {error &&
{error}
}
- {success &&
{success}
}
-
- {tab === 'models' && (
- <>
- {/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */}
-
-
Search LLM Models
-
- Query the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
-
-
-
- Adding model for task
- setSearchTaskHint(e.target.value)}>
- {TASKS.filter(t => t !== 'tts').map(t => {t} )}
-
-
-
-
- {searchLoading ? 'Searching...' : 'Search Models'}
-
-
- {searchError &&
{searchError}
}
-
- {searchResults.length > 0 && (
-
-
- {searchResults.length} models found
- setSearchFilter(e.target.value)}
- style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }}
- />
-
-
- {filteredResults.map(modelId => (
-
- {modelId}
- addFromSearch(modelId)}
- style={{ fontFamily: 'inherit' }}
- >
- + Add
-
-
- ))}
-
-
- )}
-
-
- {/* Configured models by task */}
- {TASKS.map(task => (
-
-
- {task} Models
-
- {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'}
-
-
-
- {/* TTS voice discovery — only in the tts card */}
- {task === 'tts' && (
-
-
Discover Voices
-
- { 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)' }}>
- LiteLLM Local
-
-
- {ttsVoicesLoading ? 'Searching...' : 'Search'}
-
-
- {ttsVoicesError &&
{ttsVoicesError}
}
- {ttsVoices.length > 0 && (
-
-
- {ttsVoices.length} voices — click to add
- 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)' }} />
-
-
- {ttsVoices
- .filter(v => !ttsVoicesFilter || v.name.toLowerCase().includes(ttsVoicesFilter.toLowerCase()) || v.model_id.toLowerCase().includes(ttsVoicesFilter.toLowerCase()))
- .map(v => (
-
-
-
{v.name}
- {v.labels && Object.keys(v.labels).length > 0 && (
-
- {Object.values(v.labels).filter(Boolean).join(' · ')}
-
- )}
-
{v.model_id}
-
-
addTtsFromSearch(v)}>+ Add
-
- ))}
-
-
- )}
-
- )}
-
- {modelsByTask[task].length === 0 ? (
-
No models configured
- ) : (
- modelsByTask[task].map(m => (
-
-
-
-
{m.name}
-
{m.model_id}
-
-
- {m.is_default && Default }
- {!m.is_default && (
- setDefault(m.id)}>Set Default
- )}
- {task === 'tts' ? (
- setPreview(m.id, { open: !(ttsPreviews[m.id]?.open) })}
- >Preview
- ) : (
- testModel(m)}
- disabled={testingModel === m.id}
- >{testingModel === m.id ? 'Testing...' : 'Test'}
- )}
- toggleActive(m)}
- >{m.is_active ? 'Disable' : 'Enable'}
- deleteModel(m.id)}>Remove
-
-
-
- {/* LLM test result */}
- {testResults[m.id] && (
-
- {testResults[m.id].ok ? '✓ ' : '✗ '}{testResults[m.id].message}
-
- )}
-
- {/* TTS preview */}
- {task === 'tts' && ttsPreviews[m.id]?.open && (
-
- )}
-
- ))
- )}
-
- ))}
-
- {/* Add model form */}
-
- >
- )}
-
- {tab === 'users' && (
- <>
-
-
-
Users
- {users.map(u => (
-
-
-
{u.name}
-
{u.email} · joined {new Date(u.created_at).toLocaleDateString()}
-
-
-
- {u.role}
-
- {u.is_unthrottled ? (
-
- unlimited
-
- ) : null}
- updateRole(u.id, e.target.value)}
- style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem' }}
- >
- user
- moderator
- admin
-
- toggleUnthrottle(u.id, u.is_unthrottled)}
- style={{ fontSize: '0.75rem', padding: '3px 8px' }}
- >
- {u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'}
-
- {u.id !== user?.id && (
- deleteUser(u.id, u.name || u.email)}
- style={{ fontSize: '0.75rem', padding: '3px 8px' }}
- >
- Delete
-
- )}
-
-
- ))}
-
- >
- )}
-
- {tab === 'settings' && (
-
-
Semantic search
-
-
- {/* Embedding Model */}
-
-
Embedding Model
-
- Model used to generate semantic search vectors. Must output {1024} dimensions to match the database schema.
- Current: {settings.embedding_model || '(not set)'}
-
-
- setSettings(s => ({ ...s, embedding_model: e.target.value }))}
- placeholder="e.g. ge-gemini-embedding-001"
- style={{ flex: 1, minWidth: 0 }}
- />
- saveEmbeddingModel(settings.embedding_model)}>Save
-
- {embedSearchLoading ? 'Searching...' : 'Search LiteLLM'}
-
-
- {embedTestLoading ? 'Testing...' : 'Test'}
-
-
- {regenLoading ? 'Starting…' : 'Regenerate All'}
-
-
- {embedTestResult && (
-
- {embedTestResult.ok
- ? `OK — ${embedTestResult.model} · ${embedTestResult.dimensions} dims`
- : embedTestResult.error}
-
- )}
- {embedSearchError &&
{embedSearchError}
}
- {embedSearchResults.length > 0 && (
-
-
- {embedSearchResults.length} embedding models found — click to select
- setEmbedSearchFilter(e.target.value)}
- style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 180 }}
- />
-
-
- {embedSearchResults
- .filter(m => !embedSearchFilter || m.toLowerCase().includes(embedSearchFilter.toLowerCase()))
- .map(modelId => (
-
- {modelId}
- saveEmbeddingModel(modelId)}>
- Select
-
-
- ))
- }
-
-
- )}
-
-
-
-
- )}
-
- )
-}
diff --git a/frontend/src/pages/SettingsPage.css b/frontend/src/pages/SettingsPage.css
index e56143d..264af11 100644
--- a/frontend/src/pages/SettingsPage.css
+++ b/frontend/src/pages/SettingsPage.css
@@ -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; }
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index 4b64b2f..1304360 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -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 (
-
- )
-}
-
const DOCS_PREVIEW = 5
function DocumentsSection() {
@@ -446,14 +430,17 @@ export default function SettingsPage() {
) },
{ key: 'people', group: 'The site', icon: '👥', label: 'People',
- render: () => },
+ render: () => (
+
+ ) },
{ key: 'models', group: 'The site', icon: '🧠', label: 'AI models',
- render: () => },
- { key: 'search', group: 'The site', icon: '🔎', label: 'Search',
- render: () => },
+ render: () => (
+
+ ) },
] : []),
]
@@ -488,7 +475,9 @@ export default function SettingsPage() {
))}
- {active.render()}
+ {/* Scrolls inside itself so the page does not end halfway down a tall
+ screen, and a long section does not run off the bottom. */}
+ {active.render()}
)
}
diff --git a/frontend/src/pages/SettingsPage.test.jsx b/frontend/src/pages/SettingsPage.test.jsx
index c5a7a36..fba48f8 100644
--- a/frontend/src/pages/SettingsPage.test.jsx
+++ b/frontend/src/pages/SettingsPage.test.jsx
@@ -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()