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:
2026-07-11 17:46:40 +08:00
co-authored by Claude Sonnet 4.6
parent a07c9f0910
commit b9ab94c9da
14 changed files with 277 additions and 19 deletions
+90
View File
@@ -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>
)
}