Initial import: property management SaaS + security hardening + admin dashboard

Property Management Network — Next.js 16 (App Router), Better Auth,
Drizzle ORM over PostgreSQL, Stripe, OpenAI, Resend.

Includes:
- Security hardening: access-control/IDOR fixes, TLS-by-default DB layer,
  constant-time cron auth, strict security headers, atomic AI quota gating,
  HTML/email output encoding, demo-backdoor disabled in production.
- Superadmin dashboard at /admin (overview/MRR, server-paginated users with
  ban/impersonate/plan/delete, billing, platform activity + admin audit log,
  AI usage, system health) via the Better Auth admin plugin.
- Seed/migration utility scripts under scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-06-23 20:36:07 -04:00
co-authored by Claude Opus 4.8
commit 857b9a7811
291 changed files with 38996 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation"
import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { tenants as tenantsTable, properties } from "@/lib/db/schema"
import { getSessionUser } from "@/lib/session"
import { BackButton } from "@/components/ui/back-button"
import { RentCsvImport } from "./rent-csv-import"
export const metadata = { title: "Import Rent Payments" }
export default async function ImportRentPage() {
const user = await getSessionUser()
if (!user) redirect("/login")
const tenants = await db.query.tenants.findMany({
where: and(eq(tenantsTable.user_id, user.id), eq(tenantsTable.status, "active")),
columns: { id: true, first_name: true, last_name: true },
with: {
unit: { columns: { unit_number: true } },
property: { columns: { name: true } },
},
})
const properties_ = await db
.select({ id: properties.id, name: properties.name })
.from(properties)
.where(eq(properties.user_id, user.id))
return (
<div className="mx-auto max-w-3xl space-y-6">
<BackButton href="/rent" />
<div>
<h2 className="text-lg font-semibold text-white">Import Rent Payments</h2>
<p className="text-sm text-white/40">Upload a CSV file to add multiple rent payments at once</p>
</div>
<RentCsvImport tenants={tenants ?? []} properties={properties_ ?? []} />
</div>
)
}
@@ -0,0 +1,194 @@
"use client"
import { useState, useRef } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Upload, FileText, CheckCircle, XCircle, Download } from "lucide-react"
interface Tenant { id: string; first_name: string; last_name: string; unit?: any; property?: any }
interface Property { id: string; name: string }
interface ParsedRow {
tenant_id: string
property_id: string
amount: number
due_date: string
status: string
payment_method?: string
notes?: string
_tenantName?: string
_error?: string
}
const SAMPLE_CSV = `tenant_id,property_id,amount,due_date,status,payment_method,notes
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-05-01,pending,,
TENANT_UUID_HERE,PROPERTY_UUID_HERE,1500.00,2026-04-01,paid,bank_transfer,April rent`
export function RentCsvImport({ tenants, properties }: { tenants: Tenant[]; properties: Property[] }) {
const router = useRouter()
const fileRef = useRef<HTMLInputElement>(null)
const [rows, setRows] = useState<ParsedRow[]>([])
const [importing, setImporting] = useState(false)
const [done, setDone] = useState(false)
function downloadSample() {
const blob = new Blob([SAMPLE_CSV], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url; a.download = "rent-import-sample.csv"; a.click()
URL.revokeObjectURL(url)
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const { parse } = await import("papaparse")
parse(file, {
header: true,
skipEmptyLines: true,
complete: (result) => {
const parsed: ParsedRow[] = (result.data as any[]).map((row) => {
const tenant = tenants.find((t) => t.id === row.tenant_id?.trim())
const amount = parseFloat(row.amount)
const errors: string[] = []
if (!tenant) errors.push("tenant not found")
if (isNaN(amount) || amount <= 0) errors.push("invalid amount")
if (!row.due_date?.trim()) errors.push("missing due_date")
if (!row.property_id?.trim()) errors.push("missing property_id")
return {
tenant_id: row.tenant_id?.trim(),
property_id: row.property_id?.trim(),
amount,
due_date: row.due_date?.trim(),
status: row.status?.trim() || "pending",
payment_method: row.payment_method?.trim() || undefined,
notes: row.notes?.trim() || undefined,
_tenantName: tenant ? `${tenant.first_name} ${tenant.last_name}` : row.tenant_id,
_error: errors.length ? errors.join(", ") : undefined,
}
})
setRows(parsed)
setDone(false)
},
})
}
async function handleImport() {
const valid = rows.filter((r) => !r._error)
if (!valid.length) { toast.error("No valid rows to import"); return }
setImporting(true)
let success = 0, failed = 0
for (const row of valid) {
const res = await fetch("/api/rent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tenant_id: row.tenant_id,
property_id: row.property_id,
amount: row.amount,
due_date: row.due_date,
status: row.status,
payment_method: row.payment_method,
notes: row.notes,
}),
})
res.ok ? success++ : failed++
}
setImporting(false)
setDone(true)
toast.success(`Imported ${success} payment${success !== 1 ? "s" : ""}${failed ? `, ${failed} failed` : ""}`)
if (success > 0) router.refresh()
}
const validCount = rows.filter((r) => !r._error).length
const invalidCount = rows.filter((r) => !!r._error).length
return (
<div className="space-y-5">
{/* Instructions */}
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-5 space-y-3">
<h3 className="text-sm font-semibold text-white">How it works</h3>
<ol className="space-y-1.5 text-sm text-white/50 list-decimal list-inside">
<li>Download the sample CSV template</li>
<li>Fill in tenant_id and property_id from your dashboard</li>
<li>Upload the filled CSV file</li>
<li>Review rows and click Import</li>
</ol>
<button
onClick={downloadSample}
className="flex items-center gap-2 rounded-lg border border-white/10 px-3 py-2 text-xs text-white/60 hover:border-white/20 hover:text-white transition"
>
<Download className="h-3.5 w-3.5" /> Download Sample CSV
</button>
</div>
{/* Upload */}
<div
onClick={() => fileRef.current?.click()}
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed border-white/10 bg-white/[0.02] p-10 hover:border-indigo-500/40 hover:bg-indigo-500/5 transition"
>
<Upload className="h-8 w-8 text-white/20" />
<p className="text-sm text-white/40">Click to upload CSV file</p>
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleFile} />
</div>
{/* Preview */}
{rows.length > 0 && (
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<div className="flex items-center gap-3">
<FileText className="h-4 w-4 text-indigo-400" />
<h3 className="text-sm font-semibold text-white">{rows.length} rows parsed</h3>
</div>
<div className="flex items-center gap-3 text-xs">
{validCount > 0 && <span className="text-emerald-400">{validCount} valid</span>}
{invalidCount > 0 && <span className="text-red-400">{invalidCount} invalid</span>}
</div>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
{rows.map((row, i) => (
<div key={i} className="flex items-center justify-between px-5 py-3 gap-4">
<div className="flex items-center gap-3 min-w-0">
{row._error
? <XCircle className="h-4 w-4 shrink-0 text-red-400" />
: <CheckCircle className="h-4 w-4 shrink-0 text-emerald-400" />
}
<div className="min-w-0">
<p className="text-sm text-white truncate">{row._tenantName}</p>
<p className="text-xs text-white/40">{row.due_date} · ${row.amount}</p>
{row._error && <p className="text-xs text-red-400">{row._error}</p>}
</div>
</div>
<span className="shrink-0 rounded-md border border-white/10 px-2 py-0.5 text-xs text-white/50 capitalize">
{row.status}
</span>
</div>
))}
</div>
{!done && (
<div className="border-t border-white/[0.06] p-4">
<button
onClick={handleImport}
disabled={importing || validCount === 0}
className="w-full rounded-lg bg-indigo-600 py-2.5 text-sm font-medium text-white hover:bg-indigo-500 transition disabled:opacity-50"
>
{importing ? `Importing…` : `Import ${validCount} Payment${validCount !== 1 ? "s" : ""}`}
</button>
</div>
)}
{done && (
<div className="border-t border-white/[0.06] p-4 text-center text-sm text-emerald-400">
Import complete!
</div>
)}
</div>
)}
</div>
)
}