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:
@@ -0,0 +1,203 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { tenants, rent_payments, maintenance_requests, leases } from "@/lib/db/schema"
|
||||
import { formatCurrency, formatDate } from "@/lib/utils"
|
||||
import { Home, DollarSign, Wrench, FileText } from "lucide-react"
|
||||
import { TenantMaintenanceForm } from "./tenant-maintenance-form"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TenantPortalPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params
|
||||
|
||||
// Find tenant by portal token (PUBLIC — no session filter)
|
||||
const tenant = (await db.query.tenants.findFirst({
|
||||
where: eq(tenants.portal_token, token),
|
||||
with: {
|
||||
unit: {
|
||||
columns: { unit_number: true, rent_amount: true, property_id: true },
|
||||
with: {
|
||||
property: { columns: { name: true, address_line1: true, city: true, state: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})) as any
|
||||
|
||||
if (!tenant) notFound()
|
||||
|
||||
const property = tenant.unit?.property
|
||||
const unit = tenant.unit
|
||||
|
||||
// Get rent payments for this tenant
|
||||
const payments = await db
|
||||
.select({
|
||||
amount: rent_payments.amount,
|
||||
due_date: rent_payments.due_date,
|
||||
paid_date: rent_payments.paid_date,
|
||||
status: rent_payments.status,
|
||||
payment_method: rent_payments.payment_method,
|
||||
})
|
||||
.from(rent_payments)
|
||||
.where(eq(rent_payments.tenant_id, tenant.id))
|
||||
.orderBy(desc(rent_payments.due_date))
|
||||
.limit(12)
|
||||
|
||||
// Get maintenance requests for this tenant
|
||||
const maintenance = await db
|
||||
.select({
|
||||
title: maintenance_requests.title,
|
||||
status: maintenance_requests.status,
|
||||
priority: maintenance_requests.priority,
|
||||
created_at: maintenance_requests.created_at,
|
||||
resolved_at: maintenance_requests.resolved_at,
|
||||
})
|
||||
.from(maintenance_requests)
|
||||
.where(eq(maintenance_requests.tenant_id, tenant.id))
|
||||
.orderBy(desc(maintenance_requests.created_at))
|
||||
|
||||
// Get active lease for this tenant
|
||||
const lease = await db.query.leases.findFirst({
|
||||
where: and(eq(leases.tenant_id, tenant.id), eq(leases.status, "active")),
|
||||
columns: {
|
||||
lease_start: true,
|
||||
lease_end: true,
|
||||
rent_amount: true,
|
||||
security_deposit: true,
|
||||
lease_type: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentStatusColors: Record<string, string> = {
|
||||
paid: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||
pending: "text-amber-400 bg-amber-500/10 border-amber-500/20",
|
||||
overdue: "text-red-400 bg-red-500/10 border-red-500/20",
|
||||
partial: "text-blue-400 bg-blue-500/10 border-blue-500/20",
|
||||
}
|
||||
|
||||
const maintenanceStatusColors: Record<string, string> = {
|
||||
open: "text-amber-400 bg-amber-500/10 border-amber-500/20",
|
||||
in_progress: "text-blue-400 bg-blue-500/10 border-blue-500/20",
|
||||
resolved: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
|
||||
closed: "text-white/40 bg-white/5 border-white/10",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#09090b] text-white">
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-600">
|
||||
<Home className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-white/40">Tenant Portal</p>
|
||||
<h1 className="text-lg font-bold text-white">
|
||||
{tenant.first_name} {tenant.last_name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-white/60">
|
||||
{property && (
|
||||
<>
|
||||
<p>{property.name}</p>
|
||||
<p className="text-white/40 text-xs">
|
||||
{property.address_line1}{property.city ? `, ${property.city}` : ""}{property.state ? `, ${property.state}` : ""}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{unit && <p>Unit {unit.unit_number} · {formatCurrency(unit.rent_amount)}/mo</p>}
|
||||
{tenant.email && <p className="text-white/40 text-xs">{tenant.email}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lease Info */}
|
||||
{lease && (
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.06] px-5 py-4">
|
||||
<FileText className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">Current Lease</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 p-5 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: "Start Date", value: formatDate(lease.lease_start) },
|
||||
{ label: "End Date", value: formatDate(lease.lease_end) },
|
||||
{ label: "Monthly Rent", value: formatCurrency(lease.rent_amount) },
|
||||
{ label: "Deposit", value: lease.security_deposit ? formatCurrency(lease.security_deposit) : "—" },
|
||||
].map((item) => (
|
||||
<div key={item.label}>
|
||||
<p className="text-xs text-white/40">{item.label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rent Payments */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.06] px-5 py-4">
|
||||
<DollarSign className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">Rent History</h2>
|
||||
</div>
|
||||
{!payments?.length ? (
|
||||
<p className="px-5 py-8 text-sm text-center text-white/30">No payment records yet</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{payments.map((p, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-5 py-3">
|
||||
<div>
|
||||
<p className="text-sm text-white">Due {formatDate(p.due_date)}</p>
|
||||
{p.paid_date && <p className="text-xs text-white/40">Paid {formatDate(p.paid_date)}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-medium text-white">{formatCurrency(p.amount)}</p>
|
||||
<span className={`rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${paymentStatusColors[p.status] ?? paymentStatusColors.pending}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Maintenance */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden">
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.06] px-5 py-4">
|
||||
<Wrench className="h-4 w-4 text-indigo-400" />
|
||||
<h2 className="text-sm font-semibold text-white">Maintenance Requests</h2>
|
||||
</div>
|
||||
|
||||
{!maintenance?.length ? (
|
||||
<p className="px-5 py-6 text-sm text-center text-white/30">No maintenance requests</p>
|
||||
) : (
|
||||
<div className="divide-y divide-white/[0.04]">
|
||||
{maintenance.map((m, i) => (
|
||||
<div key={i} className="flex items-center justify-between px-5 py-3">
|
||||
<div>
|
||||
<p className="text-sm text-white">{m.title}</p>
|
||||
<p className="text-xs text-white/40">{formatDate(m.created_at)}</p>
|
||||
</div>
|
||||
<span className={`rounded-md border px-2 py-0.5 text-xs font-medium capitalize ${maintenanceStatusColors[m.status] ?? maintenanceStatusColors.open}`}>
|
||||
{m.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/[0.06] p-5">
|
||||
<TenantMaintenanceForm tenantId={tenant.id} propertyId={tenant.unit?.property_id} unitId={tenant.unit_id} portalToken={token} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-white/20">
|
||||
Powered by Property Management Network · This is a private link for your use only
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user