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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
@@ -9,7 +9,6 @@ export function CheckoutButton({
|
||||
highlight,
|
||||
interval = "month",
|
||||
annualAvailable = false,
|
||||
paypalEnabled = false,
|
||||
}: {
|
||||
plan: string
|
||||
label: string
|
||||
@@ -18,11 +17,8 @@ export function CheckoutButton({
|
||||
// When true, show a monthly/annual choice. Only pass this for subscription
|
||||
// plans and only when annual billing is actually configured server-side.
|
||||
annualAvailable?: boolean
|
||||
// When true, also offer "Pay with PayPal" using the same interval choice.
|
||||
paypalEnabled?: boolean
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [paypalLoading, setPaypalLoading] = useState(false)
|
||||
const [chosenInterval, setChosenInterval] = useState<"month" | "year">(interval)
|
||||
|
||||
const effectiveInterval = annualAvailable ? chosenInterval : interval
|
||||
@@ -39,21 +35,6 @@ export function CheckoutButton({
|
||||
else setLoading(false)
|
||||
}
|
||||
|
||||
async function handlePaypal() {
|
||||
setPaypalLoading(true)
|
||||
const res = await fetch("/api/paypal/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan, interval: effectiveInterval }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
else {
|
||||
setPaypalLoading(false)
|
||||
if (data.error) alert(data.error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{annualAvailable && (
|
||||
@@ -82,7 +63,7 @@ export function CheckoutButton({
|
||||
)}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading || paypalLoading}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
@@ -92,22 +73,6 @@ export function CheckoutButton({
|
||||
>
|
||||
{loading ? "Loading..." : label}
|
||||
</button>
|
||||
|
||||
{paypalEnabled && (
|
||||
<button
|
||||
onClick={handlePaypal}
|
||||
disabled={loading || paypalLoading}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg bg-[#ffc439] py-2 text-xs font-bold text-[#003087] transition hover:bg-[#f0b90b] disabled:opacity-50"
|
||||
>
|
||||
{paypalLoading ? (
|
||||
"Loading..."
|
||||
) : (
|
||||
<>
|
||||
Pay with <span className="font-extrabold italic">Pay<span className="text-[#009cde]">Pal</span></span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
|
||||
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle, Download } from "lucide-react"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
|
||||
|
||||
@@ -12,11 +13,12 @@ type Req = {
|
||||
status: string
|
||||
signer_email: string
|
||||
document_name: string | null
|
||||
signed_document_url: string | null
|
||||
sent_at: string | null
|
||||
completed_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
type Prov = { id: string; label: string; configured: boolean }
|
||||
type Prov = { id: string; label: string }
|
||||
|
||||
const STATUS: Record<string, { label: string; cls: string; Icon: typeof Clock }> = {
|
||||
sent: { label: "Awaiting signature", cls: "text-amber-400 bg-amber-500/10 border-amber-500/20", Icon: Clock },
|
||||
@@ -29,18 +31,17 @@ const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_s
|
||||
|
||||
export function EsignLease({
|
||||
leaseId,
|
||||
providers,
|
||||
connected,
|
||||
requests,
|
||||
canSend,
|
||||
disabledReason,
|
||||
}: {
|
||||
leaseId: string
|
||||
providers: Prov[]
|
||||
connected: Prov[]
|
||||
requests: Req[]
|
||||
canSend: boolean
|
||||
disabledReason: string
|
||||
}) {
|
||||
const configured = providers.filter((p) => p.configured)
|
||||
const [pending, start] = useTransition()
|
||||
|
||||
function send(provider: string) {
|
||||
@@ -74,6 +75,16 @@ export function EsignLease({
|
||||
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
|
||||
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
|
||||
</p>
|
||||
{r.signed_document_url && (
|
||||
<a
|
||||
href={r.signed_document_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-[11px] text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
<Download className="h-3 w-3" /> Signed document
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<span className={`flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${s.cls}`}>
|
||||
<s.Icon className="h-3 w-3" /> {s.label}
|
||||
@@ -84,11 +95,16 @@ export function EsignLease({
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{configured.length === 0 ? (
|
||||
<p className="text-xs text-white/30">Configure DocuSign or Dropbox Sign on the server to send leases for e-signature.</p>
|
||||
{connected.length === 0 ? (
|
||||
<p className="text-xs text-white/30">
|
||||
<Link href="/settings/integrations" className="text-indigo-400 hover:text-indigo-300">
|
||||
Connect DocuSign or Dropbox Sign
|
||||
</Link>{" "}
|
||||
in Settings → Integrations to send leases for signature.
|
||||
</p>
|
||||
) : canSend ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{configured.map((p) => (
|
||||
{connected.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => send(p.id)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useRef, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { FileText, Upload, ExternalLink, Loader2 } from "lucide-react"
|
||||
import { setLeaseDocument } from "@/app/actions/esign"
|
||||
|
||||
export function LeaseDocument({
|
||||
leaseId,
|
||||
documentUrl,
|
||||
canWrite,
|
||||
}: {
|
||||
leaseId: string
|
||||
documentUrl: string | null
|
||||
canWrite: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (!/\.(pdf|docx?)$/i.test(file.name)) {
|
||||
toast.error("Upload a PDF or Word document")
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
return
|
||||
}
|
||||
setUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
fd.append("scope", "documents")
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}))
|
||||
throw new Error(j?.error || "Upload failed")
|
||||
}
|
||||
const { url } = (await res.json()) as { url: string }
|
||||
await setLeaseDocument(leaseId, url)
|
||||
toast.success(documentUrl ? "Lease document replaced" : "Lease document attached")
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (inputRef.current) inputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-white">
|
||||
<FileText className="h-4 w-4 shrink-0 text-indigo-400" />
|
||||
{documentUrl ? "Lease document" : "No lease document yet"}
|
||||
</div>
|
||||
{documentUrl && (
|
||||
<a
|
||||
href={documentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
View <ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!documentUrl && (
|
||||
<p className="mt-1.5 text-xs text-white/40">Upload the lease PDF to enable sending it for e-signature.</p>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx"
|
||||
onChange={onPick}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
id={`lease-doc-${leaseId}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`lease-doc-${leaseId}`}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-white/10 px-3 py-1.5 text-xs font-medium text-white/70 transition hover:bg-white/[0.06] hover:text-white ${
|
||||
uploading ? "pointer-events-none opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
{documentUrl ? "Replace document" : "Upload lease document"}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
export function PaypalCancelButton() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleClick() {
|
||||
if (!confirm("Cancel your PayPal subscription? You'll keep access until the end of the current billing period.")) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/paypal/cancel", { method: "POST" })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
window.location.href = "/settings/billing?canceled=true"
|
||||
} else {
|
||||
setLoading(false)
|
||||
alert(data.error || "Could not cancel the subscription.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:border-white/20 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Canceling..." : "Cancel Subscription"}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user