diff --git a/app/api/credentials/[provider]/route.ts b/app/api/credentials/[provider]/route.ts new file mode 100644 index 0000000..cfc7bef --- /dev/null +++ b/app/api/credentials/[provider]/route.ts @@ -0,0 +1,39 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +) { + const { provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/provider/${provider}`, + ); + return NextResponse.json(data, { status }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ provider: string }> }, +) { + const { provider } = await params; + try { + const { status, data } = await apiClient( + request, + `/api/v1/credentials/provider/${provider}`, + { method: "DELETE" }, + ); + if (status === 204) { + return new NextResponse(null, { status: 204 }); + } + + return NextResponse.json(data, { status }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/api/credentials/route.ts b/app/api/credentials/route.ts new file mode 100644 index 0000000..70a8969 --- /dev/null +++ b/app/api/credentials/route.ts @@ -0,0 +1,37 @@ +import { apiClient } from "@/app/lib/apiClient"; +import { NextResponse, NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + try { + const { status, data } = await apiClient(request, "/api/v1/credentials/"); + return NextResponse.json(data, { status }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient(request, "/api/v1/credentials/", { + method: "POST", + body: JSON.stringify(body), + }); + return NextResponse.json(data, { status }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const { status, data } = await apiClient(request, "/api/v1/credentials/", { + method: "PATCH", + body: JSON.stringify(body), + }); + return NextResponse.json(data, { status }); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 500 }); + } +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 7105146..79be552 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -97,6 +97,15 @@ export default function Sidebar({ collapsed, activeRoute = '/evaluations' }: Sid { name: 'Library', route: '/configurations' }, { name: 'Prompt Editor', route: '/configurations/prompt-editor' }, ] + }, + { + name: 'Settings', + route: '/settings/credentials', + icon: ( + + + + ), } ]; diff --git a/app/components/settings/credentials/CredentialForm.tsx b/app/components/settings/credentials/CredentialForm.tsx new file mode 100644 index 0000000..f9417a6 --- /dev/null +++ b/app/components/settings/credentials/CredentialForm.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { colors } from "@/app/lib/colors"; +import Loader from "@/app/components/Loader"; +import { Credential, ProviderDef } from "@/app/lib/types/credentials"; +import { timeAgo } from "@/app/lib/utils"; + +interface Props { + provider: ProviderDef; + existingCredential: Credential | null; + formValues: Record; + isActive: boolean; + isLoading: boolean; + isSaving: boolean; + isDeleting?: boolean; + visibleFields: Set; + onChange: (key: string, value: string) => void; + onActiveChange: (active: boolean) => void; + onToggleVisibility: (key: string) => void; + onSave: () => void; + onCancel: () => void; + onDelete?: () => void; +} + +export default function CredentialForm({ + provider, + existingCredential, + formValues, + isActive, + isLoading, + isSaving, + isDeleting, + visibleFields, + onChange, + onActiveChange, + onToggleVisibility, + onSave, + onCancel, + onDelete, +}: Props) { + return ( +
+

+ {provider.name} +

+

+ {provider.description} +

+ + {isLoading ? ( + + ) : ( +
+ {/* Active toggle */} + + + {/* Fields */} + {provider.fields.map((field) => { + const isPassword = field.type === "password"; + const visible = visibleFields.has(field.key); + const hasValue = !!formValues[field.key]; + return ( +
+ +
+ onChange(field.key, e.target.value)} + placeholder={field.placeholder} + className="w-full px-4 py-2.5 rounded-lg border text-sm outline-none transition-colors" + style={{ + borderColor: colors.border, + backgroundColor: colors.bg.primary, + color: colors.text.primary, + paddingRight: isPassword || hasValue ? "5rem" : undefined, + }} + onFocus={(e) => { + e.target.style.borderColor = colors.accent.primary; + }} + onBlur={(e) => { + e.target.style.borderColor = colors.border; + }} + /> +
+ {hasValue && ( + + )} + {isPassword && ( + + )} +
+
+
+ ); + })} + + {/* Last updated */} + {existingCredential && ( +

+ Last updated:{" "} + {existingCredential.updated_at + ? timeAgo(existingCredential.updated_at) + : "—"} +

+ )} + + {/* Actions */} +
+ + + {existingCredential && onDelete && ( + + )} +
+
+ )} +
+ ); +} diff --git a/app/components/settings/credentials/ProviderList.tsx b/app/components/settings/credentials/ProviderList.tsx new file mode 100644 index 0000000..0d3804c --- /dev/null +++ b/app/components/settings/credentials/ProviderList.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { colors } from "@/app/lib/colors"; +import { Credential, ProviderDef } from "@/app/lib/types/credentials"; +import { getExistingForProvider } from "@/app/lib/utils"; + +interface Props { + providers: ProviderDef[]; + selectedProvider: ProviderDef; + credentials: Credential[]; + onSelect: (provider: ProviderDef) => void; +} + +export default function ProviderList({ + providers, + selectedProvider, + credentials, + onSelect, +}: Props) { + return ( +
+
+ +
+
+ ); +} diff --git a/app/lib/apiClient.ts b/app/lib/apiClient.ts new file mode 100644 index 0000000..7d78323 --- /dev/null +++ b/app/lib/apiClient.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; + +const BACKEND_URL = + process.env.BACKEND_URL || "http://localhost:8000"; + +/** + * Passthrough proxy helper for Next.js route handlers. + * Extracts X-API-KEY from the incoming request and forwards it to the backend. + * Returns raw { status, data } so the route handler can relay the exact HTTP status. + */ +export async function apiClient( + request: NextRequest | Request, + endpoint: string, + options: RequestInit = {}, +) { + const apiKey = request.headers.get("X-API-KEY") || ""; + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + headers.set("X-API-KEY", apiKey); + + const response = await fetch(`${BACKEND_URL}${endpoint}`, { + ...options, + headers, + }); + + // 204 No Content has no body + const data = response.status === 204 ? null : await response.json(); + + return { status: response.status, data }; +} + +/** + * Client-side fetch helper for Next.js route handlers (/api/*). + * Attaches the X-API-KEY header and throws on non-OK responses. + * Use this in "use client" pages instead of raw fetch calls. + */ +export async function apiFetch( + url: string, + apiKey: string, + options: RequestInit = {}, +): Promise { + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + headers.set("X-API-KEY", apiKey); + const res = await fetch(url, { + ...options, + headers, + }); + const data = await res.json(); + if (!res.ok) + throw new Error( + data.error || data.message || `Request failed: ${res.status}`, + ); + return data as T; +} diff --git a/app/lib/types/credentials.ts b/app/lib/types/credentials.ts new file mode 100644 index 0000000..8a112b7 --- /dev/null +++ b/app/lib/types/credentials.ts @@ -0,0 +1,104 @@ +export interface FieldDef { + key: string; + label: string; + placeholder: string; + type?: "text" | "password"; +} + +export interface ProviderDef { + id: string; + name: string; + description: string; + /** Matches the `provider` field returned by the API */ + credentialKey: string; + fields: FieldDef[]; +} + +export interface Credential { + id: number | string; + provider: string; + is_active: boolean; + credential: Record; + inserted_at?: string; + updated_at?: string; +} + +export const PROVIDERS: ProviderDef[] = [ + { + id: "openai", + name: "OpenAI", + description: "Connect your OpenAI account to use GPT models in evaluations", + credentialKey: "openai", + fields: [ + { + key: "api_key", + label: "API Key", + placeholder: "sk-xxxxx-xxxxx-xxxxx", + type: "password", + }, + ], + }, + { + id: "langfuse", + name: "Langfuse", + description: "Integrate Langfuse for LLM observability and tracing", + credentialKey: "langfuse", + fields: [ + { + key: "secret_key", + label: "Secret Key", + placeholder: "sk-lf-xxxxx", + type: "password", + }, + { key: "public_key", label: "Public Key", placeholder: "pk-lf-xxxxx" }, + { + key: "host", + label: "Host URL", + placeholder: "https://cloud.langfuse.com", + }, + ], + }, + { + id: "google", + name: "Google", + description: + "Use Google AI (Gemini) models for speech and text evaluations", + credentialKey: "google", + fields: [ + { + key: "api_key", + label: "API Key", + placeholder: "AIzaSy-xxxxx", + type: "password", + }, + ], + }, + { + id: "elevenlabs", + name: "ElevenLabs", + description: "High-quality text-to-speech synthesis via ElevenLabs", + credentialKey: "elevenlabs", + fields: [ + { + key: "api_key", + label: "API Key", + placeholder: "sk_xxxxx", + type: "password", + }, + ], + }, + { + id: "sarvamai", + name: "Sarvam AI", + description: "Indian language speech and text models via Sarvam AI", + credentialKey: "sarvamai", + fields: [ + { + key: "api_key", + label: "API Key", + placeholder: "xxxxx", + type: "password", + }, + ], + }, +]; diff --git a/app/lib/utils.ts b/app/lib/utils.ts new file mode 100644 index 0000000..3327498 --- /dev/null +++ b/app/lib/utils.ts @@ -0,0 +1,13 @@ +import { Credential, ProviderDef } from "@/app/lib/types/credentials"; +import { formatDistanceToNow } from "date-fns"; + +export function timeAgo(dateStr: string): string { + return formatDistanceToNow(new Date(dateStr), { addSuffix: true }); +} + +export function getExistingForProvider( + provider: ProviderDef, + creds: Credential[], +): Credential | null { + return creds.find((c) => c.provider === provider.credentialKey) || null; +} diff --git a/app/settings/credentials/page.tsx b/app/settings/credentials/page.tsx new file mode 100644 index 0000000..d7efab2 --- /dev/null +++ b/app/settings/credentials/page.tsx @@ -0,0 +1,308 @@ +/** + * Credentials Settings Page — orchestrator + * State management and API calls only. UI split into: + * ProviderList — left sidebar nav + * CredentialForm — right form with fields and actions + */ + +"use client"; + +import { useState, useEffect } from "react"; +import Sidebar from "@/app/components/Sidebar"; +import { colors } from "@/app/lib/colors"; +import { useToast } from "@/app/components/Toast"; +import { APIKey, STORAGE_KEY } from "@/app/keystore/page"; +import { + PROVIDERS, + Credential, + ProviderDef, +} from "@/app/lib/types/credentials"; +import { getExistingForProvider } from "@/app/lib/utils"; +import ProviderList from "@/app/components/settings/credentials/ProviderList"; +import CredentialForm from "@/app/components/settings/credentials/CredentialForm"; +import { apiFetch } from "@/app/lib/apiClient"; +import Link from "next/link"; + +export default function CredentialsPage() { + const toast = useToast(); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [apiKeys, setApiKeys] = useState([]); + const [selectedProvider, setSelectedProvider] = useState( + PROVIDERS[0], + ); + const [credentials, setCredentials] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [formValues, setFormValues] = useState>({}); + const [isActive, setIsActive] = useState(true); + const [visibleFields, setVisibleFields] = useState>(new Set()); + const [existingCredential, setExistingCredential] = + useState(null); + + // Load API keys from localStorage + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + setApiKeys(JSON.parse(stored)); + } catch { + /* ignore */ + } + } + }, []); + + // Load credentials once we have an API key + useEffect(() => { + if (apiKeys.length === 0) return; + loadCredentials(); + }, [apiKeys]); + + // Re-populate form when provider or credentials change + useEffect(() => { + const existing = getExistingForProvider(selectedProvider, credentials); + if (existing) { + setExistingCredential(existing); + setIsActive(existing.is_active); + const populated: Record = {}; + selectedProvider.fields.forEach((f) => { + populated[f.key] = existing.credential[f.key] || ""; + }); + setFormValues(populated); + } else { + setExistingCredential(null); + setIsActive(true); + const blank: Record = {}; + selectedProvider.fields.forEach((f) => { + blank[f.key] = ""; + }); + setFormValues(blank); + } + setVisibleFields(new Set()); + }, [selectedProvider, credentials]); + + const loadCredentials = async () => { + setIsLoading(true); + try { + const data = await apiFetch<{ data?: Credential[] } | Credential[]>( + "/api/credentials", + apiKeys[0].key, + ); + setCredentials(Array.isArray(data) ? data : data.data || []); + } catch (err) { + console.error("Failed to load credentials:", err); + } finally { + setIsLoading(false); + } + }; + + const buildCredentialBody = (isUpdate: boolean) => { + const innerPayload: Record = {}; + selectedProvider.fields.forEach((f) => { + innerPayload[f.key] = formValues[f.key].trim(); + }); + return { + provider: selectedProvider.credentialKey, + is_active: isActive, + credential: isUpdate + ? innerPayload + : { [selectedProvider.credentialKey]: innerPayload }, + }; + }; + + const handleSave = async () => { + if (apiKeys.length === 0) { + toast.error("Please add an API key in Keystore first"); + return; + } + const missing = selectedProvider.fields.filter( + (f) => !formValues[f.key]?.trim(), + ); + if (missing.length > 0) { + toast.error(`Please fill in: ${missing.map((f) => f.label).join(", ")}`); + return; + } + + setIsSaving(true); + try { + if (existingCredential) { + await apiFetch("/api/credentials", apiKeys[0].key, { + method: "PATCH", + body: JSON.stringify(buildCredentialBody(true)), + }); + toast.success(`${selectedProvider.name} credentials updated`); + } else { + await apiFetch("/api/credentials", apiKeys[0].key, { + method: "POST", + body: JSON.stringify(buildCredentialBody(false)), + }); + toast.success(`${selectedProvider.name} credentials saved`); + } + await loadCredentials(); + } catch (err: any) { + toast.error(err.message || "Failed to save credentials"); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + const existing = getExistingForProvider(selectedProvider, credentials); + if (existing) { + setIsActive(existing.is_active); + const populated: Record = {}; + selectedProvider.fields.forEach((f) => { + populated[f.key] = existing.credential[f.key] || ""; + }); + setFormValues(populated); + } else { + const blank: Record = {}; + selectedProvider.fields.forEach((f) => { + blank[f.key] = ""; + }); + setFormValues(blank); + setIsActive(true); + } + setVisibleFields(new Set()); + }; + + const handleDelete = async () => { + if (!existingCredential || apiKeys.length === 0) return; + setIsDeleting(true); + try { + await apiFetch( + `/api/credentials/${selectedProvider.credentialKey}`, + apiKeys[0].key, + { method: "DELETE" }, + ); + toast.success(`${selectedProvider.name} credentials removed`); + await loadCredentials(); + } catch (err: any) { + toast.error(err.message || "Failed to remove credentials"); + } finally { + setIsDeleting(false); + } + }; + + const handleFieldChange = (key: string, value: string) => { + setFormValues((prev) => ({ ...prev, [key]: value })); + }; + + const handleToggleVisibility = (key: string) => { + setVisibleFields((prev) => { + const next = new Set(prev); + next.has(key) ? next.delete(key) : next.add(key); + return next; + }); + }; + + return ( +
+
+ + +
+
+
+ +
+

+ Settings +

+

+ Manage provider credentials +

+
+
+
+ +
+ + +
+ {apiKeys.length === 0 ? ( +
+ No API key found. Please add one in{" "} + + Keystore + {" "} + first. +
+ ) : ( + + )} +
+
+
+
+
+ ); +}