Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { RefreshCw, Link2, CheckCircle2, AlertTriangle, BookOpen } from "lucide-react"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { syncAccountingNow, disconnectAccounting } from "@/app/actions/accounting"
|
||||
|
||||
type Conn = { provider: string; orgName: string | null; status: string; lastSyncAt: string | null; lastError: string | null }
|
||||
type Prov = { id: string; label: string; configured: boolean }
|
||||
|
||||
const ERR_MSG: Record<string, string> = {
|
||||
connect_failed: "Connection failed — please try again.",
|
||||
invalid_state: "The callback was invalid. Please retry the connection.",
|
||||
owner_only: "Only the account owner can manage integrations.",
|
||||
not_configured: "That provider isn't configured on the server yet.",
|
||||
unknown_provider: "Unknown provider.",
|
||||
}
|
||||
|
||||
export function AccountingIntegrations({
|
||||
providers,
|
||||
connections,
|
||||
isOwner,
|
||||
flash,
|
||||
}: {
|
||||
providers: Prov[]
|
||||
connections: Conn[]
|
||||
isOwner: boolean
|
||||
flash: { connected?: string; error?: string }
|
||||
}) {
|
||||
const connByProvider: Record<string, Conn> = Object.fromEntries(connections.map((c) => [c.provider, c]))
|
||||
const [pending, start] = useTransition()
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (flash.connected) toast.success(`Connected to ${flash.connected === "quickbooks" ? "QuickBooks" : "Xero"}`)
|
||||
if (flash.error) toast.error(ERR_MSG[flash.error] ?? "Something went wrong")
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function sync(id: string) {
|
||||
setBusy(id)
|
||||
start(async () => {
|
||||
try {
|
||||
const r = await syncAccountingNow(id)
|
||||
toast.success(`Synced ${r.income} income + ${r.expense} expense entries`)
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Sync failed")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
if (!confirm("Disconnect this integration? Future income/expenses won't sync until you reconnect.")) return
|
||||
setBusy(id)
|
||||
start(async () => {
|
||||
try {
|
||||
await disconnectAccounting(id)
|
||||
toast.success("Disconnected")
|
||||
} catch {
|
||||
toast.error("Couldn't disconnect")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6 text-sm text-white/50">
|
||||
Only the account owner can manage accounting integrations.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{providers.map((p) => {
|
||||
const conn = connByProvider[p.id]
|
||||
const isBusy = pending && busy === p.id
|
||||
return (
|
||||
<div key={p.id} className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white/[0.04] text-white/70">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{p.label}</p>
|
||||
{conn ? (
|
||||
<p className="text-xs text-white/40">
|
||||
{conn.orgName ?? "Connected"} · Last sync {conn.lastSyncAt ? formatDate(conn.lastSyncAt) : "never"}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-white/40">Push rent income & expenses to {p.label}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{conn ? (
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
|
||||
conn.status === "error"
|
||||
? "border-red-500/20 bg-red-500/10 text-red-400"
|
||||
: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{conn.status === "error" ? <AlertTriangle className="h-3 w-3" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
{conn.status === "error" ? "Error" : "Connected"}
|
||||
</span>
|
||||
) : p.configured ? null : (
|
||||
<span className="shrink-0 rounded-full border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] font-medium text-amber-400">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conn?.status === "error" && conn.lastError && (
|
||||
<p className="mt-3 rounded-lg border border-red-500/15 bg-red-500/[0.06] px-3 py-2 text-xs text-red-300/90">{conn.lastError}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{conn ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => sync(p.id)}
|
||||
disabled={isBusy}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${isBusy ? "animate-spin" : ""}`} /> Sync now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => remove(p.id)}
|
||||
disabled={isBusy}
|
||||
className="rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60 transition hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</>
|
||||
) : p.configured ? (
|
||||
<a
|
||||
href={`/api/integrations/${p.id}/connect`}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-indigo-500"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" /> Connect {p.label}
|
||||
</a>
|
||||
) : (
|
||||
<p className="text-xs text-white/30">
|
||||
Set this provider's OAuth credentials on the server to enable connecting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<p className="px-1 text-[11px] text-white/30">
|
||||
One-way sync — paid rent is pushed as income and expenses as bills/purchases. We never read or modify existing data in your books.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user