1adc4806fa
Security Fixes
File Change
.env.local Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File Change
src/app/api/leads/route.ts Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts Added limit (default 50), offset query params
src/app/api/conversations/route.ts Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File Change
src/lib/ai.ts Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx "Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below) Added console.warn("...") context messages
103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
import { PageHeader } from "@/components/shared/page-header"
|
|
import { StatCard } from "@/components/dashboard/stat-card"
|
|
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
|
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
|
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
|
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
|
import {
|
|
Users,
|
|
UserPlus,
|
|
PhoneCall,
|
|
Clock,
|
|
CheckCircle2,
|
|
TrendingUp,
|
|
ListFilter,
|
|
} from "lucide-react"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { DashboardStats } from "@/types"
|
|
|
|
export default function DashboardPage() {
|
|
const [period, setPeriod] = useState("6months")
|
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
async function fetchStats(p: string) {
|
|
try {
|
|
const res = await fetch(`/api/dashboard?period=${p}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setStats(data)
|
|
}
|
|
} catch {
|
|
console.warn("Failed to fetch dashboard stats")
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchStats(period)
|
|
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
|
return () => {
|
|
if (pollingRef.current) clearInterval(pollingRef.current)
|
|
}
|
|
}, [period])
|
|
|
|
const statCards = stats
|
|
? [
|
|
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
|
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
|
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
|
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
|
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
|
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
|
]
|
|
: []
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader title="Dashboard" description="Overview of your sales pipeline">
|
|
<Select value={period} onValueChange={setPeriod}>
|
|
<SelectTrigger className="h-9 w-[160px]">
|
|
<ListFilter className="mr-2 h-4 w-4" />
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="7days">Last 7 days</SelectItem>
|
|
<SelectItem value="30days">Last 30 days</SelectItem>
|
|
<SelectItem value="6months">Last 6 months</SelectItem>
|
|
<SelectItem value="12months">Last 12 months</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</PageHeader>
|
|
|
|
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
{stats
|
|
? statCards.map((card, i) => (
|
|
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
|
))
|
|
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
|
}
|
|
</div>
|
|
|
|
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
|
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
|
</div>
|
|
|
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
|
</div>
|
|
)
|
|
}
|