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,250 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Camera, Loader2, X, Home } from "lucide-react"
|
||||
import { updateBranding } from "@/app/actions/branding"
|
||||
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
interface Props {
|
||||
brandName: string | null
|
||||
brandLogoUrl: string | null
|
||||
brandColor: string | null
|
||||
hidePoweredBy: boolean
|
||||
}
|
||||
|
||||
export function BrandingForm({ brandName, brandLogoUrl, brandColor, hidePoweredBy }: Props) {
|
||||
const router = useRouter()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const [name, setName] = useState(brandName ?? "")
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(brandLogoUrl)
|
||||
const [color, setColor] = useState(brandColor ?? "#4f46e5")
|
||||
const [hidePb, setHidePb] = useState(hidePoweredBy)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const validColor = HEX_RE.test(color)
|
||||
const previewColor = validColor ? color : "#4f46e5"
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-white/30 outline-none ring-indigo-500 transition focus:border-indigo-500/50 focus:ring-1"
|
||||
const labelClass = "mb-1.5 block text-sm font-medium text-white/70"
|
||||
|
||||
async function handleLogo(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("Please select an image file")
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error("Logo must be under 5MB")
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
fd.append("scope", "misc")
|
||||
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(typeof data.error === "string" ? data.error : "Upload failed")
|
||||
}
|
||||
const { url } = await res.json()
|
||||
setLogoUrl(url)
|
||||
toast.success("Logo uploaded")
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message ?? "Upload failed")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileRef.current) fileRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
|
||||
if (color.trim() && !validColor) {
|
||||
setError("Accent color must be a hex value like #4f46e5")
|
||||
return
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.set("brand_name", name)
|
||||
fd.set("brand_logo_url", logoUrl ?? "")
|
||||
fd.set("brand_color", color.trim() && validColor ? color : "")
|
||||
if (hidePb) fd.set("hide_powered_by", "on")
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateBranding(fd)
|
||||
toast.success("Branding saved")
|
||||
router.refresh()
|
||||
} catch (err: any) {
|
||||
const msg = err?.message ?? "Failed to save branding"
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Brand Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={60}
|
||||
placeholder="Acme Property Management"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-white/30">Shown in the tenant portal header instead of “Tenant Portal”.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Logo</label>
|
||||
{logoUrl ? (
|
||||
<div className="group relative h-28 w-full overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.02]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={logoUrl} alt="Brand logo" className="h-full w-full object-contain p-3" />
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/50 opacity-0 transition group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-white/10 px-3 py-2 text-xs text-white transition hover:bg-white/20"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" /> Change
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogoUrl(null)}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-500/20 px-3 py-2 text-xs text-red-400 transition hover:bg-red-500/30"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> Remove
|
||||
</button>
|
||||
</div>
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex h-28 w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] text-white/30 transition hover:border-indigo-500/40 hover:text-white/50 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Upload logo</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleLogo} />
|
||||
<p className="mt-1 text-xs text-white/30">PNG or SVG works best. Max 5MB.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Accent Color</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={previewColor}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-10 w-12 shrink-0 cursor-pointer rounded-lg border border-white/10 bg-white/5 p-1"
|
||||
aria-label="Accent color picker"
|
||||
/>
|
||||
<input
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
placeholder="#4f46e5"
|
||||
className={inputClass + (color.trim() && !validColor ? " border-red-500/40" : "")}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-white/30">Used for highlights in the tenant portal. Hex like #RRGGBB.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-lg border border-white/[0.06] bg-white/[0.02] p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hidePb}
|
||||
onChange={(e) => setHidePb(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 accent-indigo-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-white">Remove “Powered by” branding</span>
|
||||
<span className="mt-0.5 block text-xs text-white/40">
|
||||
Hides the “Powered by Property Management Network” line in the tenant portal.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Saving..." : "Save Branding"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Live preview */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-white/30">Preview</p>
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl"
|
||||
style={{ backgroundColor: previewColor }}
|
||||
>
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoUrl} alt="Logo" className="h-full w-full object-contain p-1" />
|
||||
) : (
|
||||
<Home className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40">{name.trim() || "Tenant Portal"}</p>
|
||||
<h1 className="text-lg font-bold text-white">Jane Tenant</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/[0.06] p-3">
|
||||
<div className="flex items-center gap-2 text-sm" style={{ color: previewColor }}>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: previewColor }} />
|
||||
<span className="font-medium">Rent History</span>
|
||||
</div>
|
||||
</div>
|
||||
{!hidePb && (
|
||||
<p className="mt-4 text-center text-[10px] text-white/20">
|
||||
Powered by Property Management Network
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,33 +3,111 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function CheckoutButton({ plan, label, highlight }: { plan: string; label: string; highlight?: boolean }) {
|
||||
export function CheckoutButton({
|
||||
plan,
|
||||
label,
|
||||
highlight,
|
||||
interval = "month",
|
||||
annualAvailable = false,
|
||||
paypalEnabled = false,
|
||||
}: {
|
||||
plan: string
|
||||
label: string
|
||||
highlight?: boolean
|
||||
interval?: "month" | "year"
|
||||
// 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
|
||||
|
||||
async function handleClick() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ plan }),
|
||||
body: JSON.stringify({ plan, interval: effectiveInterval }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.url) window.location.href = data.url
|
||||
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 (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
||||
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
|
||||
<div className="space-y-2">
|
||||
{annualAvailable && (
|
||||
<div className="flex rounded-lg border border-white/10 p-0.5 text-[11px] font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChosenInterval("month")}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 transition",
|
||||
chosenInterval === "month" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
|
||||
)}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChosenInterval("year")}
|
||||
className={cn(
|
||||
"flex-1 rounded-md py-1 transition",
|
||||
chosenInterval === "year" ? "bg-white/10 text-white" : "text-white/40 hover:text-white/70"
|
||||
)}
|
||||
>
|
||||
Annual
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{loading ? "Loading..." : label}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={loading || paypalLoading}
|
||||
className={cn(
|
||||
"w-full rounded-lg py-2 text-xs font-semibold transition disabled:opacity-50",
|
||||
highlight
|
||||
? "bg-indigo-600 text-white hover:bg-indigo-500"
|
||||
: "border border-white/10 text-white/70 hover:border-white/20 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
||||
|
||||
interface DeleteTenantButtonProps {
|
||||
tenantId: string
|
||||
tenantName?: string
|
||||
/** When set, refresh in place instead of navigating to the tenants list. */
|
||||
refreshOnly?: boolean
|
||||
/** Compact icon-only variant for table rows. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function DeleteTenantButton({ tenantId, tenantName, refreshOnly, compact }: DeleteTenantButtonProps) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/tenants/${tenantId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
toast.error(data?.error ?? "Failed to delete tenant")
|
||||
return
|
||||
}
|
||||
toast.success(`${tenantName ?? "Tenant"} deleted`)
|
||||
if (refreshOnly) {
|
||||
router.refresh()
|
||||
} else {
|
||||
router.push("/tenants")
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
toast.error("Network error — please try again")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{compact ? (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setOpen(true) }}
|
||||
className="text-white/20 hover:text-red-400 transition"
|
||||
title="Delete tenant"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-500/20 px-3 py-2 text-sm text-red-400 hover:border-red-500/40 hover:bg-red-500/5 transition"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title="Delete tenant?"
|
||||
description={`Are you sure you want to delete ${tenantName ?? "this tenant"}? This frees their unit and cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
loading={loading}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { PenLine, CheckCircle2, Clock, XCircle, AlertTriangle } from "lucide-react"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { sendLeaseForSignatureAction } from "@/app/actions/esign"
|
||||
|
||||
type Req = {
|
||||
id: string
|
||||
provider: string
|
||||
status: string
|
||||
signer_email: string
|
||||
document_name: string | null
|
||||
sent_at: string | null
|
||||
completed_at: string | null
|
||||
last_error: string | null
|
||||
}
|
||||
type Prov = { id: string; label: string; configured: boolean }
|
||||
|
||||
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 },
|
||||
signed: { label: "Signed", cls: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20", Icon: CheckCircle2 },
|
||||
declined: { label: "Declined", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: XCircle },
|
||||
voided: { label: "Voided", cls: "text-white/40 bg-white/5 border-white/10", Icon: XCircle },
|
||||
error: { label: "Failed", cls: "text-red-400 bg-red-500/10 border-red-500/20", Icon: AlertTriangle },
|
||||
}
|
||||
const PROVIDER_LABEL: Record<string, string> = { docusign: "DocuSign", dropbox_sign: "Dropbox Sign" }
|
||||
|
||||
export function EsignLease({
|
||||
leaseId,
|
||||
providers,
|
||||
requests,
|
||||
canSend,
|
||||
disabledReason,
|
||||
}: {
|
||||
leaseId: string
|
||||
providers: Prov[]
|
||||
requests: Req[]
|
||||
canSend: boolean
|
||||
disabledReason: string
|
||||
}) {
|
||||
const configured = providers.filter((p) => p.configured)
|
||||
const [pending, start] = useTransition()
|
||||
|
||||
function send(provider: string) {
|
||||
start(async () => {
|
||||
try {
|
||||
await sendLeaseForSignatureAction(leaseId, provider)
|
||||
toast.success("Lease sent for signature")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message || "Couldn't send for signature")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.06] bg-[#16161f] p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<PenLine className="h-4 w-4 text-indigo-400" />
|
||||
<h3 className="text-sm font-semibold text-white">E-signature</h3>
|
||||
</div>
|
||||
|
||||
{requests.length > 0 && (
|
||||
<ul className="mb-4 space-y-2">
|
||||
{requests.map((r) => {
|
||||
const s = STATUS[r.status] ?? STATUS.sent
|
||||
return (
|
||||
<li key={r.id} className="flex items-center justify-between gap-3 rounded-xl border border-white/[0.05] bg-white/[0.02] px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-white/80">{PROVIDER_LABEL[r.provider] ?? r.provider} · {r.signer_email}</p>
|
||||
<p className="text-[11px] text-white/35">
|
||||
Sent {r.sent_at ? formatDate(r.sent_at) : "—"}
|
||||
{r.completed_at ? ` · Signed ${formatDate(r.completed_at)}` : ""}
|
||||
{r.status === "error" && r.last_error ? ` · ${r.last_error}` : ""}
|
||||
</p>
|
||||
</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}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
) : canSend ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{configured.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => send(p.id)}
|
||||
disabled={pending}
|
||||
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"
|
||||
>
|
||||
<PenLine className="h-3.5 w-3.5" /> Send via {p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-white/30">{disabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { Pencil, Ban, Trash2 } from "lucide-react"
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal"
|
||||
|
||||
type Action = "terminate" | "delete" | null
|
||||
|
||||
export function LeaseActions({ lease }: { lease: any }) {
|
||||
const router = useRouter()
|
||||
const [pending, setPending] = useState<Action>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleTerminate() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/leases/${lease.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: "terminated" }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast.error(typeof data?.error === "string" ? data.error : "Could not terminate lease")
|
||||
return
|
||||
}
|
||||
toast.success("Lease terminated")
|
||||
setPending(null)
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Something went wrong")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/leases/${lease.id}`, { method: "DELETE" })
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
toast.error(typeof data?.error === "string" ? data.error : "Could not delete lease")
|
||||
return
|
||||
}
|
||||
toast.success("Lease deleted")
|
||||
setPending(null)
|
||||
router.push("/leases")
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Something went wrong")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/leases/${lease.id}/edit`}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/60 transition hover:border-white/20 hover:text-white"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</Link>
|
||||
{lease.status !== "terminated" && (
|
||||
<button
|
||||
onClick={() => setPending("terminate")}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-amber-400/80 transition hover:border-amber-500/30 hover:text-amber-400"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
Terminate
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-sm text-white/40 transition hover:border-red-500/30 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={pending === "terminate"}
|
||||
variant="warning"
|
||||
title="Terminate this lease?"
|
||||
description="The lease will be marked as terminated. You can still view it afterwards."
|
||||
confirmLabel="Terminate"
|
||||
loading={loading}
|
||||
onConfirm={handleTerminate}
|
||||
onCancel={() => !loading && setPending(null)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={pending === "delete"}
|
||||
variant="danger"
|
||||
title="Delete this lease?"
|
||||
description="This permanently removes the lease record. This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
loading={loading}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => !loading && setPending(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function LeaseForm({ tenants, properties, lease, prefill }: {
|
||||
|
||||
setIsDirty(false)
|
||||
toast.success(lease ? "Lease updated" : "Lease created")
|
||||
router.push("/leases")
|
||||
router.push(lease ? `/leases/${lease.id}` : "/leases")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,20 @@ export function RentActions({ payment }: { payment: any }) {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
async function sendLink() {
|
||||
setLoading(true)
|
||||
const res = await fetch("/api/rent/send-payment-link", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ payment_id: payment.id }),
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setLoading(false)
|
||||
if (res.ok) toast.success(data.message ?? "Payment link emailed to tenant")
|
||||
else toast.error(data.error ?? "Failed to send payment link")
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (loading) return <span className="text-xs text-white/30">Updating...</span>
|
||||
|
||||
return (
|
||||
@@ -40,6 +54,11 @@ export function RentActions({ payment }: { payment: any }) {
|
||||
Mark Paid
|
||||
</button>
|
||||
)}
|
||||
{payment.status !== "paid" && (
|
||||
<button onClick={sendLink} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
Send link
|
||||
</button>
|
||||
)}
|
||||
{payment.status !== "overdue" && payment.status !== "paid" && (
|
||||
<button onClick={() => markAs("overdue")} className="text-xs text-red-400 hover:text-red-300">
|
||||
Mark Overdue
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Pencil, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface UnitActionsProps {
|
||||
propertyId: string
|
||||
unitId: string
|
||||
unitNumber: string
|
||||
}
|
||||
|
||||
export function UnitActions({ propertyId, unitId, unitNumber }: UnitActionsProps) {
|
||||
const router = useRouter()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/units/${unitId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null)
|
||||
toast.error(data?.error ?? "Failed to delete unit")
|
||||
return
|
||||
}
|
||||
toast.success(`Unit ${unitNumber} deleted`)
|
||||
router.refresh()
|
||||
} catch {
|
||||
toast.error("Network error — please try again")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-white/50">Delete?</span>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-red-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-red-500 disabled:opacity-50 transition"
|
||||
>
|
||||
{loading ? "Deleting…" : "Yes"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-white/10 px-2.5 py-1.5 text-xs text-white/60 hover:text-white transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/properties/${propertyId}/units/${unitId}/edit`}
|
||||
className="rounded-lg border border-white/10 p-1.5 text-white/40 hover:border-white/20 hover:text-white transition"
|
||||
title="Edit unit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="rounded-lg border border-red-500/20 p-1.5 text-red-400/80 hover:border-red-500/40 hover:text-red-400 hover:bg-red-500/5 transition"
|
||||
title="Delete unit"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import type { Unit } from "@/types"
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "vacant", label: "Vacant" },
|
||||
@@ -14,13 +15,15 @@ const statusOptions = [
|
||||
|
||||
interface UnitFormProps {
|
||||
propertyId: string
|
||||
unit?: Unit
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
export function UnitForm({ propertyId, unit, onSuccess }: UnitFormProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [status, setStatus] = useState("vacant")
|
||||
const [status, setStatus] = useState(unit?.status ?? "vacant")
|
||||
const isEditing = Boolean(unit)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
@@ -38,21 +41,24 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
notes: formData.get("notes") || undefined,
|
||||
}
|
||||
|
||||
const res = await fetch("/api/units", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const res = await fetch(
|
||||
isEditing ? `/api/units/${unit!.id}` : "/api/units",
|
||||
{
|
||||
method: isEditing ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
toast.error(data?.error ?? "Failed to add unit")
|
||||
const data = await res.json().catch(() => null)
|
||||
toast.error(data?.error ?? (isEditing ? "Failed to update unit" : "Failed to add unit"))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success("Unit added")
|
||||
toast.success(isEditing ? "Unit updated" : "Unit added")
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
} else {
|
||||
@@ -69,26 +75,26 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Unit Number *</label>
|
||||
<input name="unit_number" required placeholder="e.g. 1A, 101" className={inputClass} />
|
||||
<input name="unit_number" required placeholder="e.g. 1A, 101" defaultValue={unit?.unit_number ?? ""} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Monthly Rent ($) *</label>
|
||||
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" className={inputClass} />
|
||||
<input name="rent_amount" type="number" required min={0} step={0.01} placeholder="1500" defaultValue={unit?.rent_amount ?? ""} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Bedrooms</label>
|
||||
<input name="bedrooms" type="number" min={0} defaultValue={1} className={inputClass} />
|
||||
<input name="bedrooms" type="number" min={0} defaultValue={unit?.bedrooms ?? 1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Bathrooms</label>
|
||||
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={1} className={inputClass} />
|
||||
<input name="bathrooms" type="number" min={0} step={0.5} defaultValue={unit?.bathrooms ?? 1} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Sq Ft</label>
|
||||
<input name="sq_ft" type="number" min={0} placeholder="Optional" className={inputClass} />
|
||||
<input name="sq_ft" type="number" min={0} placeholder="Optional" defaultValue={unit?.sq_ft ?? ""} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,13 +103,13 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
<Select
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
onChange={(v) => setStatus(v as "vacant" | "occupied" | "maintenance" | "unavailable")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea name="notes" rows={3} placeholder="Optional notes..." className={`${inputClass} resize-none`} />
|
||||
<textarea name="notes" rows={3} placeholder="Optional notes..." defaultValue={unit?.notes ?? ""} className={`${inputClass} resize-none`} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
@@ -119,7 +125,7 @@ export function UnitForm({ propertyId, onSuccess }: UnitFormProps) {
|
||||
disabled={loading}
|
||||
className="flex-1 rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Adding…" : "Add Unit"}
|
||||
{loading ? (isEditing ? "Saving…" : "Adding…") : isEditing ? "Save Changes" : "Add Unit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user