Files Changed
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
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -90,6 +90,9 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_CACHED_CONVERSATIONS = 5
|
||||
const otherParticipant = (conv: any) => conv.otherUser
|
||||
|
||||
export default function ChatsPage() {
|
||||
const { theme } = useTheme()
|
||||
const { user } = useUser()
|
||||
@@ -129,6 +132,8 @@ export default function ChatsPage() {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const recordingChunksRef = useRef<Blob[]>([])
|
||||
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||
const blobUrlsRef = useRef<string[]>([])
|
||||
const conversationOrderRef = useRef<string[]>([])
|
||||
|
||||
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
||||
|
||||
@@ -141,12 +146,12 @@ export default function ChatsPage() {
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const messages = conversationMessages.get(activeChat || "") || []
|
||||
const conversation = conversations.find((c) => c.id === activeChat)
|
||||
const otherParticipant = (conv: any) => conv.otherUser
|
||||
const filteredConversations = searchQuery.trim()
|
||||
? conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: conversations
|
||||
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
||||
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
||||
const filteredConversations = useMemo(() => {
|
||||
if (!searchQuery.trim()) return conversations
|
||||
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
}, [conversations, searchQuery])
|
||||
|
||||
// Fetch conversations from API
|
||||
useEffect(() => {
|
||||
@@ -161,7 +166,7 @@ export default function ChatsPage() {
|
||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch conversations in chats page")
|
||||
} finally {
|
||||
setLoadingChats(false)
|
||||
}
|
||||
@@ -172,6 +177,10 @@ export default function ChatsPage() {
|
||||
// Fetch messages when active chat changes, and mark as read
|
||||
useEffect(() => {
|
||||
if (!activeChat) return
|
||||
conversationOrderRef.current = [
|
||||
activeChat,
|
||||
...conversationOrderRef.current.filter((id) => id !== activeChat),
|
||||
]
|
||||
const fetchMsgs = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${activeChat}/messages`)
|
||||
@@ -180,6 +189,30 @@ export default function ChatsPage() {
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(activeChat, data.messages || [])
|
||||
const recent = conversationOrderRef.current.slice(0, MAX_CACHED_CONVERSATIONS)
|
||||
const recentSet = new Set(recent)
|
||||
for (const key of next.keys()) {
|
||||
if (!recentSet.has(key)) next.delete(key)
|
||||
}
|
||||
const validMsgIds = new Set<string>()
|
||||
for (const msgs of next.values()) {
|
||||
for (const m of msgs) validMsgIds.add(m.id)
|
||||
}
|
||||
setVoiceMessages((v) => {
|
||||
const n = new Map(v)
|
||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
||||
return n
|
||||
})
|
||||
setMessageAttachments((a) => {
|
||||
const n = new Map(a)
|
||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
||||
return n
|
||||
})
|
||||
setReplyMap((r) => {
|
||||
const n = new Map(r)
|
||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
||||
return n
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -206,7 +239,7 @@ export default function ChatsPage() {
|
||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch { console.warn("Failed to search users in chats page") }
|
||||
setSearchingUsers(false)
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
@@ -222,6 +255,14 @@ export default function ChatsPage() {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// revoke blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
||||
blobUrlsRef.current = []
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
@@ -254,8 +295,16 @@ export default function ChatsPage() {
|
||||
setTimeout(() => autoResizeTextarea(), 0)
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
||||
if (oversized.length > 0) {
|
||||
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
||||
if (e.target) e.target.value = ""
|
||||
return
|
||||
}
|
||||
setAttachments((prev) => [...prev, ...files])
|
||||
if (e.target) e.target.value = ""
|
||||
}
|
||||
@@ -319,6 +368,7 @@ export default function ChatsPage() {
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to send message in chats page")
|
||||
toast.error("Failed to send message")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +391,11 @@ export default function ChatsPage() {
|
||||
return
|
||||
}
|
||||
const fileAttachments = attachments.length > 0
|
||||
? attachments.map((f) => ({ name: f.name, type: f.type, url: URL.createObjectURL(f) }))
|
||||
? attachments.map((f) => {
|
||||
const url = URL.createObjectURL(f)
|
||||
blobUrlsRef.current.push(url)
|
||||
return { name: f.name, type: f.type, url }
|
||||
})
|
||||
: undefined
|
||||
if (replyingTo) {
|
||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||
@@ -356,6 +410,10 @@ export default function ChatsPage() {
|
||||
}
|
||||
|
||||
const handleDeleteMessage = (messageId: string) => {
|
||||
const files = messageAttachments.get(messageId)
|
||||
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
||||
const voice = voiceMessages.get(messageId)
|
||||
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
||||
setConversationMessages((prev) => {
|
||||
const next = new Map(prev)
|
||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||
@@ -383,6 +441,7 @@ export default function ChatsPage() {
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
blobUrlsRef.current.push(url)
|
||||
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
setRecordingDuration(0)
|
||||
@@ -392,6 +451,7 @@ export default function ChatsPage() {
|
||||
setIsRecording(true)
|
||||
setIsPaused(false)
|
||||
} catch {
|
||||
console.warn("Failed to start recording in chats page")
|
||||
toast.error("Microphone access denied")
|
||||
}
|
||||
}
|
||||
@@ -425,6 +485,7 @@ export default function ChatsPage() {
|
||||
setIsPaused(false)
|
||||
setRecordingDuration(0)
|
||||
recordingDurationRef.current = 0
|
||||
recordingChunksRef.current = []
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -532,7 +593,7 @@ export default function ChatsPage() {
|
||||
setSearchQuery("")
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch { console.warn("Failed to create conversation in chats page") }
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
@@ -911,6 +972,7 @@ export default function ChatsPage() {
|
||||
toast.success("Message forwarded")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to forward message in chats page")
|
||||
toast.error("Failed to forward message")
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function DashboardPage() {
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch dashboard stats")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AppShell } from "@/components/layout/app-shell"
|
||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||
import { NotificationProvider } from "@/providers/notification-provider"
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
@@ -29,7 +30,9 @@ export default function DashboardLayout({
|
||||
return (
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
<ErrorBoundary>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
</ErrorBoundary>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
@@ -26,22 +26,26 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes, notesRes] = await Promise.all([
|
||||
const [leadRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetch(`/api/leads/${id}/notes`),
|
||||
fetchNotes(),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch lead details")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id])
|
||||
}, [id, fetchNotes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -89,8 +93,19 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={(v) => {
|
||||
onValueChange={async (v) => {
|
||||
const previousStatus = lead.status
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: v }),
|
||||
})
|
||||
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
} catch {
|
||||
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
@@ -125,7 +140,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<NoteForm leadId={lead.id} />
|
||||
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useForm } from "react-hook-form"
|
||||
@@ -26,9 +26,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { Lead, LeadStatus } from "@/types"
|
||||
import { leads } from "@/data/leads"
|
||||
import { users } from "@/data/users"
|
||||
import { User } from "@/types"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
|
||||
@@ -49,6 +47,14 @@ export default function CreateLeadPage() {
|
||||
const router = useRouter()
|
||||
const { addNotification } = useNotifications()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
resolver: zodResolver(leadFormSchema),
|
||||
@@ -64,35 +70,27 @@ export default function CreateLeadPage() {
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(values: LeadFormValues) {
|
||||
async function onSubmit(values: LeadFormValues) {
|
||||
setSaving(true)
|
||||
const now = new Date().toISOString()
|
||||
const assignedUserId: string | null = values.assignedUserId === "none" ? null : (values.assignedUserId ?? null)
|
||||
const assignedUser = assignedUserId ? users.find((u) => u.id === assignedUserId) ?? null : null
|
||||
|
||||
const newLead: Lead = {
|
||||
id: `lead-${String(leads.length + 1).padStart(3, "0")}`,
|
||||
companyName: values.companyName,
|
||||
contactName: values.contactName,
|
||||
email: values.email,
|
||||
phone: values.phone ?? "",
|
||||
source: values.source ?? "",
|
||||
description: values.description ?? "",
|
||||
status: values.status as LeadStatus,
|
||||
assignedUserId,
|
||||
assignedUser,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||
}
|
||||
const res = await fetch("/api/leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create lead")
|
||||
const data = await res.json()
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||
router.push("/leads")
|
||||
} catch (e) {
|
||||
console.error("Create lead error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
leads.unshift(newLead)
|
||||
addNotification(
|
||||
"lead_created",
|
||||
"New Lead Created",
|
||||
`${newLead.companyName} — ${newLead.contactName}`,
|
||||
`/leads/${newLead.id}`
|
||||
)
|
||||
router.push("/leads")
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function ProfilePage() {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save avatar")
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function UsersPage() {
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
console.warn("Failed to fetch users in users page")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user