- 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
91 lines
3.3 KiB
TypeScript
91 lines
3.3 KiB
TypeScript
'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>
|
|
)
|
|
}
|