"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { PasswordInput } from "./_shared"; /** * v1.16.6 — operator panel for the server-wide AI key. * * The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has * existed since the `admin-openai` chain fallback shipped, but it never had * an operator UI — configuring the shared key took a manual API call. This * card is its home, beside the assistant feature flags. * * Semantics worth keeping visible to the operator: * - A user's own key (BYOK / Codex OAuth / local) always wins; the server * key sits at the END of the provider chain as the shared fallback. * - Egress over the server key is consent-gated per user * (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a * key here never silently forwards anyone's health data. * - The base URL is locked server-side to HTTPS + a hostname allowlist * (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it). */ interface AiServerKeyResponse { hasKey: boolean; keyPreview: string | null; model: string; baseUrl: string; } export function AiServerKeySection() { const { t } = useTranslations(); const queryClient = useQueryClient(); const { data: settings } = useQuery({ queryKey: queryKeys.adminAiServerKey(), queryFn: () => apiGet("/api/admin/ai-settings"), }); const [keyDraft, setKeyDraft] = useState(""); const [modelDraft, setModelDraft] = useState(null); const [baseUrlDraft, setBaseUrlDraft] = useState(null); const modelValue = modelDraft ?? settings?.model ?? ""; const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? ""; const configured = settings?.hasKey ?? false; const save = useMutation({ mutationFn: (payload: Record) => apiPut("/api/admin/ai-settings", payload), onSuccess: () => { setKeyDraft(""); setModelDraft(null); setBaseUrlDraft(null); queryClient.invalidateQueries({ queryKey: queryKeys.adminAiServerKey(), }); toast.success(t("admin.aiServerKey.saved")); }, onError: (error: unknown) => { toast.error( error instanceof Error ? error.message : t("admin.aiServerKey.error"), ); }, }); function handleSave() { const payload: Record = { model: modelValue, baseUrl: baseUrlValue, }; if (keyDraft.trim().length > 0) { payload.apiKey = keyDraft.trim(); } save.mutate(payload); } function handleRemove() { save.mutate({ apiKey: "" }); } return (

{t("admin.aiServerKey.description")}

{t("admin.aiServerKey.byokHint")}

{t("admin.aiServerKey.consentHint")}

} status={ {configured ? t("admin.aiServerKey.statusConfigured") : t("admin.aiServerKey.statusNotConfigured")} } />
setKeyDraft(e.target.value)} placeholder={ configured && settings?.keyPreview ? t("admin.aiServerKey.keyPlaceholderSet", { preview: settings.keyPreview, }) : t("admin.aiServerKey.keyPlaceholder") } autoComplete="off" />
setModelDraft(e.target.value)} placeholder="gpt-4o" />
setBaseUrlDraft(e.target.value)} placeholder="https://api.openai.com/v1" />
{configured && ( )}
); }