diff --git a/app/(protected)/hse/settings/page.tsx b/app/(protected)/hse/settings/page.tsx new file mode 100644 index 0000000..5fb3c47 --- /dev/null +++ b/app/(protected)/hse/settings/page.tsx @@ -0,0 +1,32 @@ +export const dynamic = 'force-dynamic' + +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { ApiKeyForm } from '@/components/settings/api-key-form' + +export default async function SettingsPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + if (!profile || profile.role !== 'admin') redirect('/hse/dashboard') + + const { data: settings } = await supabase + .from('app_settings') + .select('key, value, updated_at') + + const settingsMap = Object.fromEntries( + (settings ?? []).map(s => [s.key, { set: Boolean(s.value), updated_at: s.updated_at }]) + ) + + return ( +
+

Settings

+

+ API keys are stored securely in the database. Leave blank to use environment variables. +

+ +
+ ) +} diff --git a/app/api/incidents/[id]/ai/rca-draft/route.ts b/app/api/incidents/[id]/ai/rca-draft/route.ts index d684219..e6214fe 100644 --- a/app/api/incidents/[id]/ai/rca-draft/route.ts +++ b/app/api/incidents/[id]/ai/rca-draft/route.ts @@ -2,7 +2,8 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { anthropic } from '@/lib/claude/client' +import { createAnthropicClient } from '@/lib/claude/client' +import { getApiKey } from '@/lib/settings' export async function POST( _request: NextRequest, @@ -17,6 +18,9 @@ export async function POST( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') + const anthropic = createAnthropicClient(anthropicKey) + const { data: incident } = await supabase .from('incidents') .select(` diff --git a/app/api/incidents/[id]/ai/triage-suggest/route.ts b/app/api/incidents/[id]/ai/triage-suggest/route.ts index 08d9279..8362486 100644 --- a/app/api/incidents/[id]/ai/triage-suggest/route.ts +++ b/app/api/incidents/[id]/ai/triage-suggest/route.ts @@ -2,7 +2,8 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { anthropic } from '@/lib/claude/client' +import { createAnthropicClient } from '@/lib/claude/client' +import { getApiKey } from '@/lib/settings' export async function POST( _request: NextRequest, @@ -17,6 +18,9 @@ export async function POST( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') + const anthropic = createAnthropicClient(anthropicKey) + const { data: incident } = await supabase .from('incidents') .select('id, incident_type, description, injury_involved, asset_involved, medical_status') diff --git a/app/api/incidents/[id]/similar/route.ts b/app/api/incidents/[id]/similar/route.ts index 19d1f01..666b22c 100644 --- a/app/api/incidents/[id]/similar/route.ts +++ b/app/api/incidents/[id]/similar/route.ts @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { embedText } from '@/lib/claude/embed' +import { getApiKey } from '@/lib/settings' export async function GET( _request: NextRequest, @@ -17,6 +18,8 @@ export async function GET( if (!profile || !['hse', 'admin'].includes(profile.role)) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + const voyageKey = await getApiKey(supabase, 'VOYAGE_API_KEY') + const { data: incident } = await supabase .from('incidents') .select('id, description, embedding') @@ -31,7 +34,7 @@ export async function GET( if (inc.embedding) { embeddingVec = JSON.parse(inc.embedding) as number[] } else { - embeddingVec = await embedText(inc.description) + embeddingVec = await embedText(inc.description, voyageKey) await supabase.from('incidents').update({ embedding: `[${embeddingVec.join(',')}]` as unknown as string, }).eq('id', id) diff --git a/app/api/incidents/ai/quality-check/route.ts b/app/api/incidents/ai/quality-check/route.ts index 1f0019c..0f22dcc 100644 --- a/app/api/incidents/ai/quality-check/route.ts +++ b/app/api/incidents/ai/quality-check/route.ts @@ -2,13 +2,17 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { anthropic } from '@/lib/claude/client' +import { createAnthropicClient } from '@/lib/claude/client' +import { getApiKey } from '@/lib/settings' export async function POST(request: NextRequest) { const supabase = await createClient() const { data: { user }, error: authError } = await supabase.auth.getUser() if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY') + const anthropic = createAnthropicClient(anthropicKey) + let body: { description?: string; incident_type?: string } try { body = await request.json() diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 4c7a76f..f772fc4 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -110,11 +110,16 @@ export async function POST(request: Request) { .catch(err => console.error('email notification failed:', err)) // Embed description asynchronously for future similarity search - import('@/lib/claude/embed').then(({ embedText }) => - embedText(input.description.trim()).then(embedding => - supabase.from('incidents').update({ - embedding: `[${embedding.join(',')}]` as unknown as string, - }).eq('id', incident.id) + const supabaseForEmbed = supabase + import('@/lib/settings').then(({ getApiKey }) => + getApiKey(supabaseForEmbed, 'VOYAGE_API_KEY').then(voyageKey => + import('@/lib/claude/embed').then(({ embedText }) => + embedText(input.description.trim(), voyageKey).then(embedding => + supabase.from('incidents').update({ + embedding: `[${embedding.join(',')}]` as unknown as string, + }).eq('id', incident.id) + ) + ) ) ).catch(err => console.error('embed error:', err)) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..2f6e6d0 --- /dev/null +++ b/app/api/settings/route.ts @@ -0,0 +1,65 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +const ALLOWED_KEYS = ['ANTHROPIC_API_KEY', 'VOYAGE_API_KEY'] as const +type SettingKey = typeof ALLOWED_KEYS[number] + +async function requireAdmin(supabase: Awaited>) { + const { data: { user }, error } = await supabase.auth.getUser() + if (error || !user) return null + const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single() + if (!profile || profile.role !== 'admin') return null + return user +} + +export async function GET() { + const supabase = await createClient() + const user = await requireAdmin(supabase) + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const { data } = await supabase.from('app_settings').select('key, value, updated_at') + const masked = (data ?? []).map(row => ({ + key: row.key, + set: Boolean(row.value), + masked_value: row.value ? `${row.value.slice(0, 8)}${'•'.repeat(12)}` : '', + updated_at: row.updated_at, + })) + return NextResponse.json(masked) +} + +export async function POST(request: NextRequest) { + const supabase = await createClient() + const user = await requireAdmin(supabase) + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + let body: { key?: string; value?: string } + try { body = await request.json() } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + if (!body.key || !ALLOWED_KEYS.includes(body.key as SettingKey)) { + return NextResponse.json({ error: 'Invalid key' }, { status: 422 }) + } + if (typeof body.value !== 'string') { + return NextResponse.json({ error: 'value required' }, { status: 422 }) + } + + const { error } = await supabase.from('app_settings').upsert({ + key: body.key, + value: body.value, + updated_at: new Date().toISOString(), + updated_by: user.id, + }) + if (error) return NextResponse.json({ error: 'Save failed' }, { status: 500 }) + + await supabase.rpc('write_audit_log', { + p_table_name: 'app_settings', + p_record_id: user.id, + p_action: 'UPDATE', + p_new_value: { key: body.key, set: Boolean(body.value) } as never, + }) + + return NextResponse.json({ ok: true }) +} diff --git a/components/settings/api-key-form.tsx b/components/settings/api-key-form.tsx new file mode 100644 index 0000000..eb71402 --- /dev/null +++ b/components/settings/api-key-form.tsx @@ -0,0 +1,90 @@ +'use client' + +import { useState } from 'react' + +type SettingStatus = { set: boolean; updated_at: string | null } + +interface Props { + settings: Record +} + +const KEY_LABELS: Record = { + ANTHROPIC_API_KEY: 'Anthropic API Key (Claude)', + VOYAGE_API_KEY: 'Voyage AI API Key (Embeddings)', +} + +export function ApiKeyForm({ settings }: Props) { + const [values, setValues] = useState>({ + ANTHROPIC_API_KEY: '', + VOYAGE_API_KEY: '', + }) + const [saving, setSaving] = useState>({}) + const [results, setResults] = useState>({}) + + 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 ( +
+ {Object.keys(KEY_LABELS).map(key => { + const status = settings[key] + return ( +
+
+ + + {status?.set ? 'Configured' : 'Not set'} + +
+ {status?.set && status.updated_at && ( +

+ Last updated {new Date(status.updated_at).toLocaleDateString('en-MY')} +

+ )} +
+ 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" + /> + +
+ {results[key] === 'ok' && ( +

Saved. Reload the page to confirm.

+ )} + {results[key] === 'error' && ( +

Save failed. Check your permissions.

+ )} +
+ ) + })} +
+ ) +} diff --git a/lib/claude/client.ts b/lib/claude/client.ts index 009e3a2..76264ba 100644 --- a/lib/claude/client.ts +++ b/lib/claude/client.ts @@ -1,5 +1,5 @@ import Anthropic from '@anthropic-ai/sdk' -export const anthropic = new Anthropic({ - apiKey: process.env.ANTHROPIC_API_KEY ?? '', -}) +export function createAnthropicClient(apiKey: string): Anthropic { + return new Anthropic({ apiKey }) +} diff --git a/lib/claude/embed.ts b/lib/claude/embed.ts index a440b50..8fa6470 100644 --- a/lib/claude/embed.ts +++ b/lib/claude/embed.ts @@ -1,10 +1,11 @@ -export async function embedText(text: string): Promise { - if (!process.env.VOYAGE_API_KEY) throw new Error('VOYAGE_API_KEY is not set') +export async function embedText(text: string, apiKey?: string): Promise { + const key = apiKey ?? process.env.VOYAGE_API_KEY + if (!key) throw new Error('VOYAGE_API_KEY is not set') const res = await fetch('https://api.voyageai.com/v1/embeddings', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`, + 'Authorization': `Bearer ${key}`, }, body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }), }) diff --git a/lib/settings.ts b/lib/settings.ts new file mode 100644 index 0000000..8dbe0f1 --- /dev/null +++ b/lib/settings.ts @@ -0,0 +1,17 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export async function getApiKey(supabase: SupabaseClient, key: string): Promise { + try { + const { data } = await supabase + .from('app_settings') + .select('value') + .eq('key', key) + .single() + if (data?.value) return data.value + } catch { + // fall through to env + } + const env = process.env[key] + if (env) return env + throw new Error(`${key} not configured. Add it in Settings (/hse/settings) or as an environment variable.`) +} diff --git a/supabase/migrations/20260711000014_app_settings.sql b/supabase/migrations/20260711000014_app_settings.sql new file mode 100644 index 0000000..ff6f04f --- /dev/null +++ b/supabase/migrations/20260711000014_app_settings.sql @@ -0,0 +1,25 @@ +create table if not exists app_settings ( + key text primary key, + value text not null, + updated_at timestamptz not null default now(), + updated_by uuid references users(id) +); + +-- Only admins can read or write settings +alter table app_settings enable row level security; + +create policy "admin_select_settings" on app_settings + for select using ( + exists (select 1 from users where id = auth.uid() and role = 'admin') + ); + +create policy "admin_update_settings" on app_settings + for all using ( + exists (select 1 from users where id = auth.uid() and role = 'admin') + ); + +-- Placeholder rows (empty value means "use env var") +insert into app_settings (key, value) values + ('ANTHROPIC_API_KEY', ''), + ('VOYAGE_API_KEY', '') +on conflict (key) do nothing; diff --git a/tests/api/incidents/quality-check.test.ts b/tests/api/incidents/quality-check.test.ts index 0bd4b10..7455b9a 100644 --- a/tests/api/incidents/quality-check.test.ts +++ b/tests/api/incidents/quality-check.test.ts @@ -9,7 +9,7 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/claude/client', () => ({ - anthropic: { + createAnthropicClient: vi.fn().mockReturnValue({ messages: { create: vi.fn().mockResolvedValue({ content: [{ @@ -19,7 +19,11 @@ vi.mock('@/lib/claude/client', () => ({ }], }), }, - }, + }), +})) + +vi.mock('@/lib/settings', () => ({ + getApiKey: vi.fn().mockResolvedValue('sk-test-key'), })) describe('POST /api/incidents/ai/quality-check', () => { diff --git a/tests/api/incidents/triage-suggest.test.ts b/tests/api/incidents/triage-suggest.test.ts index 1f61b52..418320f 100644 --- a/tests/api/incidents/triage-suggest.test.ts +++ b/tests/api/incidents/triage-suggest.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/supabase/server', () => ({ })) vi.mock('@/lib/claude/client', () => ({ - anthropic: { + createAnthropicClient: vi.fn().mockReturnValue({ messages: { create: vi.fn().mockResolvedValue({ content: [{ @@ -43,7 +43,11 @@ vi.mock('@/lib/claude/client', () => ({ }], }), }, - }, + }), +})) + +vi.mock('@/lib/settings', () => ({ + getApiKey: vi.fn().mockResolvedValue('sk-test-key'), })) describe('POST /api/incidents/[id]/ai/triage-suggest', () => {