fix: test chat uses OIDC auth via /api/chat + /api/models, no key needed

This commit is contained in:
2026-06-29 12:17:34 +02:00
parent 1f5469bce1
commit ea2b24ed62
3 changed files with 25 additions and 78 deletions

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react'
const CHAT_KEY_STORAGE = 'chat_api_key'
import { getToken } from '../lib/api'
interface Message {
role: 'user' | 'assistant'
@@ -10,12 +9,7 @@ interface Message {
interface ModelOption { id: string }
function getChatKey() { return localStorage.getItem(CHAT_KEY_STORAGE) ?? '' }
function setChatKey(k: string) { localStorage.setItem(CHAT_KEY_STORAGE, k) }
export default function Chat() {
const [apiKey, setApiKey] = useState(getChatKey)
const [keyInput, setKeyInput] = useState('')
const [models, setModels] = useState<ModelOption[]>([])
const [model, setModel] = useState('')
const [messages, setMessages] = useState<Message[]>([])
@@ -26,34 +20,20 @@ export default function Chat() {
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
if (!apiKey) return
fetch('/v1/models', { headers: { Authorization: `Bearer ${apiKey}` } })
.then(r => r.ok ? r.json() : Promise.reject(r.status))
fetch('/api/models', { headers: { Authorization: `Bearer ${getToken()}` } })
.then(r => r.json())
.then(d => {
const list: ModelOption[] = d.data ?? []
setModels(list)
if (list.length > 0) setModel(list[0].id)
setError('')
})
.catch(() => {
setError('Could not load models — check your API key.')
setModels([])
})
}, [apiKey])
.catch(() => setError('Failed to load models'))
}, [])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
function saveKey() {
const k = keyInput.trim()
if (!k) return
setChatKey(k)
setApiKey(k)
setKeyInput('')
setError('')
}
async function send() {
const text = input.trim()
if (!text || streaming || !model) return
@@ -68,12 +48,12 @@ export default function Chat() {
setStreaming(true)
try {
const res = await fetch('/v1/chat/completions', {
const res = await fetch('/api/chat/completions', {
method: 'POST',
signal: abort.signal,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify({
model,
@@ -82,9 +62,7 @@ export default function Chat() {
}),
})
if (!res.ok) {
throw new Error(await res.text().catch(() => res.statusText))
}
if (!res.ok) throw new Error(await res.text().catch(() => res.statusText))
const reader = res.body!.getReader()
const decoder = new TextDecoder()
@@ -103,24 +81,16 @@ export default function Chat() {
if (data === '[DONE]') break
try {
const chunk = JSON.parse(data)
const delta = chunk.choices?.[0]?.delta?.content
if (delta) {
reply += delta
setMessages([...next, { role: 'assistant', content: reply }])
}
const delta = chunk.choices?.[0]?.delta?.content ?? chunk.choices?.[0]?.delta?.reasoning_content ?? ''
if (delta) { reply += delta; setMessages([...next, { role: 'assistant', content: reply }]) }
} catch {}
}
}
setMessages([...next, { role: 'assistant', content: reply || '(empty response)' }])
} catch (e: any) {
if (e.name !== 'AbortError') {
setError(e.message)
setMessages(next)
} else {
setMessages(prev => prev.map((m, i) =>
i === prev.length - 1 ? { ...m, pending: false } : m))
}
if (e.name !== 'AbortError') { setError(e.message); setMessages(next) }
else setMessages(prev => prev.map((m, i) => i === prev.length - 1 ? { ...m, pending: false } : m))
} finally {
setStreaming(false)
abortRef.current = null
@@ -131,36 +101,6 @@ export default function Chat() {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
// ── Key setup screen ────────────────────────────────────────────────────────
if (!apiKey) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: 380, background: '#17171c', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 10, padding: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#f0eeff', marginBottom: 6 }}>Enter an API key to use Test Chat</div>
<div style={{ fontSize: 12, color: 'rgba(237,237,240,0.4)', marginBottom: 16 }}>
Create a key on the API Keys page, then paste it here. It's stored in your browser only.
</div>
<input
type="password" value={keyInput} onChange={e => setKeyInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && saveKey()}
placeholder="sk-…" autoFocus
style={{
width: '100%', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.09)',
borderRadius: 6, padding: '8px 12px', fontSize: 13, color: '#ededf0', outline: 'none',
fontFamily: "'DM Mono',monospace", marginBottom: 10, boxSizing: 'border-box',
}}
/>
<button onClick={saveKey} disabled={!keyInput.trim()} style={{
width: '100%', background: '#7c3aed', border: 'none', borderRadius: 7,
padding: '9px 0', fontSize: 13, fontWeight: 500, color: 'white', cursor: 'pointer',
opacity: keyInput.trim() ? 1 : 0.4,
}}>Continue</button>
</div>
</div>
)
}
// ── Chat UI ─────────────────────────────────────────────────────────────────
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, maxWidth: 780, width: '100%', margin: '0 auto' }}>
{/* Toolbar */}
@@ -172,8 +112,7 @@ export default function Chat() {
}}>
{models.length === 0
? <option value="">No models available</option>
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)
}
: models.map(m => <option key={m.id} value={m.id}>{m.id}</option>)}
</select>
{messages.length > 0 && (
<button onClick={() => { setMessages([]); setError('') }} disabled={streaming} style={{
@@ -181,10 +120,6 @@ export default function Chat() {
padding: '7px 12px', fontSize: 12, color: 'rgba(237,237,240,0.4)', cursor: 'pointer',
}}>Clear</button>
)}
<button onClick={() => { setChatKey(''); setApiKey(''); setModels([]); setMessages([]) }} style={{
background: 'transparent', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 7,
padding: '7px 10px', fontSize: 11, color: 'rgba(237,237,240,0.25)', cursor: 'pointer',
}} title="Change API key">Key</button>
</div>
{/* Messages */}

View File

@@ -89,6 +89,9 @@ func (a *API) Mount(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/api/me", a.apiMe)
// Dashboard test chat — OIDC auth, synthetic allow-all key injected.
r.With(a.dashboardKeyAuth).Post("/api/chat/completions", a.chatCompletions)
r.Get("/api/models", a.listModels)
r.Route("/api/admin", func(r chi.Router) {
// Keys and logs are user-scoped (admins see all via nil filter).

View File

@@ -73,6 +73,15 @@ func (a *API) requireAuth(next http.Handler) http.Handler {
})
}
// dashboardKeyAuth injects a synthetic allow-all virtual key so the chat handler
// works for OIDC-authenticated dashboard users without a real API key.
func (a *API) dashboardKeyAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
synthetic := &store.VirtualKey{AllowAllModels: true, KeyPrefix: "dashboard"}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyCtxKey, synthetic)))
})
}
// requireAdmin rejects requests from non-admin users. Must be composed after requireAuth.
func (a *API) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {