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,364 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
Search, LayoutDashboard, Building2, Users, CreditCard,
|
||||
Wrench, FileText, Receipt, BarChart3, Hammer, ClipboardList,
|
||||
Bot, Settings, Zap, Plus, X, ArrowRight, Loader2,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const COMMANDS = [
|
||||
{
|
||||
group: "Navigate",
|
||||
items: [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard, keywords: "home overview" },
|
||||
{ label: "Properties", href: "/properties", icon: Building2, keywords: "buildings units" },
|
||||
{ label: "Tenants", href: "/tenants", icon: Users, keywords: "renters residents" },
|
||||
{ label: "Rent Tracker", href: "/rent", icon: CreditCard, keywords: "payments collect" },
|
||||
{ label: "Maintenance", href: "/maintenance", icon: Wrench, keywords: "repair fix" },
|
||||
{ label: "Leases", href: "/leases", icon: FileText, keywords: "contracts agreements" },
|
||||
{ label: "Expenses", href: "/expenses", icon: Receipt, keywords: "costs bills" },
|
||||
{ label: "Reports", href: "/reports", icon: BarChart3, keywords: "analytics profit" },
|
||||
{ label: "Vendors", href: "/vendors", icon: Hammer, keywords: "contractors workers" },
|
||||
{ label: "Inspections", href: "/inspections", icon: ClipboardList, keywords: "checklist condition" },
|
||||
{ label: "AI Assistant", href: "/ai", icon: Bot, keywords: "ask chat" },
|
||||
{ label: "Settings", href: "/settings/profile", icon: Settings, keywords: "profile account" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Quick Actions",
|
||||
items: [
|
||||
{ label: "Add Property", href: "/properties/new", icon: Building2, keywords: "new create" },
|
||||
{ label: "Add Tenant", href: "/tenants/new", icon: Users, keywords: "new create" },
|
||||
{ label: "Record Payment", href: "/rent/new", icon: CreditCard, keywords: "new create" },
|
||||
{ label: "New Maintenance Request",href: "/maintenance/new", icon: Wrench, keywords: "new create" },
|
||||
{ label: "New Lease", href: "/leases/new", icon: FileText, keywords: "new create" },
|
||||
{ label: "Add Expense", href: "/expenses/new", icon: Receipt, keywords: "new create" },
|
||||
{ label: "Generate Rent", href: "/rent/generate", icon: Zap, keywords: "bulk all" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
type CommandItem = (typeof COMMANDS)[0]["items"][0]
|
||||
|
||||
interface SearchResults {
|
||||
tenants: { id: string; first_name: string; last_name: string; email?: string }[]
|
||||
properties: { id: string; name: string; address_line1?: string; city?: string }[]
|
||||
maintenance: { id: string; title: string; status: string; priority: string }[]
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState("")
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [liveResults, setLiveResults] = useState<SearchResults | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const router = useRouter()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Open on Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
if (e.key === "Escape") setOpen(false)
|
||||
}
|
||||
document.addEventListener("keydown", onKey)
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
setQuery("")
|
||||
setCursor(0)
|
||||
setLiveResults(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Live search debounce
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
if (query.length < 2) { setLiveResults(null); return }
|
||||
setSearching(true)
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||
if (res.ok) setLiveResults(await res.json())
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 300)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
}, [query])
|
||||
|
||||
const filtered: { group: string; items: CommandItem[] }[] = COMMANDS.map((g) => ({
|
||||
group: g.group,
|
||||
items: g.items.filter((item) => {
|
||||
if (!query) return true
|
||||
const q = query.toLowerCase()
|
||||
return item.label.toLowerCase().includes(q) || item.keywords.includes(q)
|
||||
}),
|
||||
})).filter((g) => g.items.length > 0)
|
||||
|
||||
const flat: CommandItem[] = filtered.flatMap((g) => g.items)
|
||||
|
||||
function go(href: string) {
|
||||
router.push(href)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setCursor((v) => Math.min(v + 1, flat.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setCursor((v) => Math.max(v - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
if (flat[cursor]) go(flat[cursor].href)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector(`[data-idx="${cursor}"]`) as HTMLElement
|
||||
el?.scrollIntoView({ block: "nearest" })
|
||||
}, [cursor])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
let globalIdx = 0
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-start justify-center pt-[15vh] px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-xl overflow-hidden rounded-2xl border border-white/[0.1] bg-[#16161f] shadow-2xl shadow-black/80">
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-white/[0.06] px-4 py-3.5">
|
||||
<Search className="h-4 w-4 shrink-0 text-white/30" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setCursor(0) }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Search pages, actions…"
|
||||
className="flex-1 bg-transparent text-sm text-white placeholder-white/30 outline-none"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => setQuery("")} className="text-white/30 hover:text-white transition">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<kbd className="hidden sm:flex items-center gap-1 rounded-md border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-mono text-white/30">
|
||||
esc
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={listRef} className="max-h-96 overflow-y-auto py-2">
|
||||
{/* Live data results when query length >= 2 */}
|
||||
{query.length >= 2 && (
|
||||
<>
|
||||
{searching && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 text-xs text-white/30">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Searching…
|
||||
</div>
|
||||
)}
|
||||
{liveResults && (
|
||||
<>
|
||||
{liveResults.tenants.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Tenants</p>
|
||||
{liveResults.tenants.map((t) => {
|
||||
const idx = globalIdx++
|
||||
const active = cursor === idx
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
data-idx={idx}
|
||||
onClick={() => go(`/tenants/${t.id}`)}
|
||||
onMouseEnter={() => setCursor(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
|
||||
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
|
||||
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
|
||||
)}>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{t.first_name} {t.last_name}</p>
|
||||
{t.email && <p className="text-xs text-white/30 truncate">{t.email}</p>}
|
||||
</div>
|
||||
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{liveResults.properties.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Properties</p>
|
||||
{liveResults.properties.map((p) => {
|
||||
const idx = globalIdx++
|
||||
const active = cursor === idx
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
data-idx={idx}
|
||||
onClick={() => go(`/properties/${p.id}`)}
|
||||
onMouseEnter={() => setCursor(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
|
||||
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
|
||||
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
|
||||
)}>
|
||||
<Building2 className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{p.name}</p>
|
||||
{p.city && <p className="text-xs text-white/30 truncate">{p.address_line1 ? `${p.address_line1}, ` : ""}{p.city}</p>}
|
||||
</div>
|
||||
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{liveResults.maintenance.length > 0 && (
|
||||
<div>
|
||||
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">Maintenance</p>
|
||||
{liveResults.maintenance.map((m) => {
|
||||
const idx = globalIdx++
|
||||
const active = cursor === idx
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
data-idx={idx}
|
||||
onClick={() => go(`/maintenance/${m.id}`)}
|
||||
onMouseEnter={() => setCursor(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
|
||||
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
|
||||
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
|
||||
)}>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{m.title}</p>
|
||||
<p className="text-xs text-white/30 capitalize">{m.priority} · {m.status.replace("_", " ")}</p>
|
||||
</div>
|
||||
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!searching && liveResults.tenants.length === 0 && liveResults.properties.length === 0 && liveResults.maintenance.length === 0 && filtered.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-white/30">No results for “{query}”</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Divider before nav items if there are live results */}
|
||||
{liveResults && (liveResults.tenants.length > 0 || liveResults.properties.length > 0 || liveResults.maintenance.length > 0) && filtered.length > 0 && (
|
||||
<div className="my-1 border-t border-white/[0.06]" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Static command items */}
|
||||
{filtered.length === 0 && query.length < 2 ? (
|
||||
<p className="py-10 text-center text-sm text-white/30">Type to search…</p>
|
||||
) : (
|
||||
filtered.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/20">
|
||||
{group.group}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const idx = globalIdx++
|
||||
const Icon = item.icon
|
||||
const active = cursor === idx
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
data-idx={idx}
|
||||
onClick={() => go(item.href)}
|
||||
onMouseEnter={() => setCursor(idx)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left",
|
||||
active ? "bg-indigo-600/20 text-white" : "text-white/60 hover:bg-white/[0.04] hover:text-white"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border transition-colors",
|
||||
active ? "border-indigo-500/30 bg-indigo-500/20 text-indigo-400" : "border-white/[0.06] bg-white/[0.03] text-white/30"
|
||||
)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="flex-1 font-medium">{item.label}</span>
|
||||
{active && <ArrowRight className="h-3.5 w-3.5 text-indigo-400 shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-4 border-t border-white/[0.06] px-4 py-2.5">
|
||||
{[["↑↓", "navigate"], ["↵", "open"], ["esc", "close"]].map(([key, label]) => (
|
||||
<span key={key} className="flex items-center gap-1.5 text-[10px] text-white/25">
|
||||
<kbd className="rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 font-mono">{key}</kbd>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Trigger button for the header
|
||||
export function CommandTrigger() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })
|
||||
document.dispatchEvent(e)
|
||||
}}
|
||||
className="hidden sm:flex items-center gap-2 rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs text-white/30 transition hover:border-white/[0.15] hover:text-white/60"
|
||||
>
|
||||
<Search className="h-3 w-3" />
|
||||
<span>Search…</span>
|
||||
<kbd className="ml-1 flex items-center gap-0.5 font-mono text-[10px]">
|
||||
<span>⌘</span><span>K</span>
|
||||
</kbd>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user