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:
Ace
2026-06-23 09:45:30 +02:00
parent d891197d8b
commit 1adc4806fa
51 changed files with 1145 additions and 1052 deletions
+73 -11
View File
@@ -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")
}
}}