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>
120 lines
4.7 KiB
TypeScript
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>
|
|
)
|
|
}
|