feat: API key settings page — store ANTHROPIC/VOYAGE keys in DB with admin UI
- Migration: app_settings table with admin-only RLS (ANTHROPIC_API_KEY, VOYAGE_API_KEY) - lib/settings.ts: getApiKey() reads DB first, falls back to env var - lib/claude/client.ts: factory createAnthropicClient(apiKey) replaces singleton - lib/claude/embed.ts: optional apiKey param, falls back to env - 3 Claude AI routes + similar route: fetch key from settings before calling AI - incidents/route.ts: fire-and-forget embed reads VOYAGE key from settings - GET/POST /api/settings: admin-only masked key management endpoint - /hse/settings page + ApiKeyForm client component Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
type SettingStatus = { set: boolean; updated_at: string | null }
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, SettingStatus>
|
||||
}
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
ANTHROPIC_API_KEY: 'Anthropic API Key (Claude)',
|
||||
VOYAGE_API_KEY: 'Voyage AI API Key (Embeddings)',
|
||||
}
|
||||
|
||||
export function ApiKeyForm({ settings }: Props) {
|
||||
const [values, setValues] = useState<Record<string, string>>({
|
||||
ANTHROPIC_API_KEY: '',
|
||||
VOYAGE_API_KEY: '',
|
||||
})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
const [results, setResults] = useState<Record<string, 'ok' | 'error'>>({})
|
||||
|
||||
async function save(key: string) {
|
||||
setSaving(s => ({ ...s, [key]: true }))
|
||||
setResults(r => ({ ...r, [key]: undefined as never }))
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ key, value: values[key] }),
|
||||
})
|
||||
setResults(r => ({ ...r, [key]: res.ok ? 'ok' : 'error' }))
|
||||
if (res.ok) setValues(v => ({ ...v, [key]: '' }))
|
||||
} catch {
|
||||
setResults(r => ({ ...r, [key]: 'error' }))
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [key]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.keys(KEY_LABELS).map(key => {
|
||||
const status = settings[key]
|
||||
return (
|
||||
<div key={key} className="bg-white rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-semibold text-gray-800">{KEY_LABELS[key]}</label>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
status?.set
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{status?.set ? 'Configured' : 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.set && status.updated_at && (
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Last updated {new Date(status.updated_at).toLocaleDateString('en-MY')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="password"
|
||||
value={values[key]}
|
||||
onChange={e => setValues(v => ({ ...v, [key]: e.target.value }))}
|
||||
placeholder={status?.set ? 'Enter new key to replace…' : 'Enter API key…'}
|
||||
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => save(key)}
|
||||
disabled={saving[key] || !values[key]}
|
||||
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-blue-700"
|
||||
>
|
||||
{saving[key] ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{results[key] === 'ok' && (
|
||||
<p className="text-xs text-green-600 mt-2">Saved. Reload the page to confirm.</p>
|
||||
)}
|
||||
{results[key] === 'error' && (
|
||||
<p className="text-xs text-red-600 mt-2">Save failed. Check your permissions.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user