Files
property-management-network/components/admin/ai-provider-toggle.tsx
T
Leon SerfatyandClaude Opus 4.8 5495b94924 Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:45:24 -04:00

120 lines
4.7 KiB
TypeScript

"use client"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { Sparkles, Check, AlertTriangle } from "lucide-react"
import { setAiProviderAction } from "@/app/actions/admin"
type Provider = "openai" | "anthropic"
const LABELS: Record<Provider, string> = { openai: "OpenAI", anthropic: "Anthropic (Claude)" }
export function AiProviderToggle({
selected,
effective,
openaiConfigured,
anthropicConfigured,
openaiModel,
anthropicModel,
}: {
selected: Provider
effective: Provider
openaiConfigured: boolean
anthropicConfigured: boolean
openaiModel: string
anthropicModel: string
}) {
const [current, setCurrent] = useState<Provider>(selected)
const [pending, startTransition] = useTransition()
const configured: Record<Provider, boolean> = { openai: openaiConfigured, anthropic: anthropicConfigured }
const models: Record<Provider, string> = { openai: openaiModel, anthropic: anthropicModel }
function choose(next: Provider) {
if (next === current || pending) return
const prev = current
setCurrent(next)
startTransition(async () => {
try {
await setAiProviderAction(next)
toast.success(`AI provider set to ${LABELS[next]}`)
} catch {
setCurrent(prev) // revert optimistic change
toast.error("Couldn't switch the AI provider. Try again.")
}
})
}
// When the selected provider has no key on the server, AI falls back to the
// other configured provider (see lib/ai/provider). Surface that clearly.
const fallbackActive = effective !== current
const noneConfigured = !openaiConfigured && !anthropicConfigured
const options: Provider[] = ["openai", "anthropic"]
return (
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-5 py-4">
<Sparkles className="h-4 w-4 text-rose-400 shrink-0" />
<h2 className="text-sm font-semibold text-white">AI provider</h2>
</div>
<div className="px-5 py-4 space-y-3">
<p className="text-xs text-white/40">
Choose which LLM powers all AI features (assistant, recommendations, predictions, summaries,
receipts). Applies to everyone immediately.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{options.map((p) => {
const active = current === p
return (
<button
key={p}
type="button"
onClick={() => choose(p)}
disabled={pending}
aria-pressed={active}
className={`flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-left transition disabled:opacity-60 ${
active
? "border-rose-500/40 bg-rose-500/[0.08]"
: "border-white/10 bg-white/[0.02] hover:bg-white/[0.05]"
}`}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white">{LABELS[p]}</span>
{active && <Check className="h-3.5 w-3.5 text-rose-400" />}
</div>
<p className="mt-0.5 font-mono text-[11px] text-white/40 truncate">{models[p]}</p>
<p className="mt-1 text-[11px]">
{configured[p] ? (
<span className="text-emerald-400">API key configured</span>
) : (
<span className="text-amber-400">No API key on server</span>
)}
</p>
</div>
</button>
)
})}
</div>
{noneConfigured ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
No AI provider key is set on the server AI features return a 503 until{" "}
<code className="font-mono">OPENAI_API_KEY</code> or <code className="font-mono">ANTHROPIC_API_KEY</code> is configured.
</p>
) : fallbackActive ? (
<p className="flex items-start gap-1.5 rounded-lg border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-[11px] text-amber-300/90">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
{LABELS[current]} has no API key on this server, so AI is temporarily running on{" "}
<span className="font-semibold">{LABELS[effective]}</span>. Add the key to use {LABELS[current]}.
</p>
) : null}
</div>
</div>
)
}