changes made to sanitize html to overcome the VAPT alets

This commit is contained in:
laxmanhalaki 2026-02-09 11:22:40 +05:30
parent 81565d294b
commit d285ea88d8
8 changed files with 786 additions and 756 deletions

View File

@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/components/ui/utils"; import { cn } from "@/components/ui/utils";
import { sanitizeHTML } from "@/utils/sanitizer";
interface FormattedDescriptionProps { interface FormattedDescriptionProps {
content: string; content: string;
@ -33,7 +34,8 @@ export function FormattedDescription({ content, className }: FormattedDescriptio
return `<div class="table-wrapper">${match}</div>`; return `<div class="table-wrapper">${match}</div>`;
}); });
return processed; // Sanitize the content to prevent CSP violations (onclick, style tags, etc.)
return sanitizeHTML(processed);
}, [content]); }, [content]);
if (!content) return null; if (!content) return null;

View File

@ -1,18 +1,19 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { sanitizeHTML } from '../../utils/sanitizer';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Avatar, AvatarFallback } from '../ui/avatar'; import { Avatar, AvatarFallback } from '../ui/avatar';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
Users, Users,
FileText, FileText,
Download, Download,
Eye, Eye,
MoreHorizontal MoreHorizontal
} from 'lucide-react'; } from 'lucide-react';
@ -166,7 +167,8 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
// Simple mention highlighting // Simple mention highlighting
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>'); const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
return sanitizeHTML(formatted);
}; };
return ( return (
@ -187,7 +189,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<TabsTrigger value="chat">Chat</TabsTrigger> <TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger> <TabsTrigger value="media">Media</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="chat" className="flex-1 flex flex-col"> <TabsContent value="chat" className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 border rounded-lg"> <ScrollArea className="flex-1 p-4 border rounded-lg">
<div className="space-y-4"> <div className="space-y-4">
@ -195,16 +197,15 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}> <div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
{!msg.isSystem && ( {!msg.isSystem && (
<Avatar className="h-8 w-8 flex-shrink-0"> <Avatar className="h-8 w-8 flex-shrink-0">
<AvatarFallback className={`text-white text-xs ${ <AvatarFallback className={`text-white text-xs ${msg.user.role === 'Initiator' ? 'bg-re-green' :
msg.user.role === 'Initiator' ? 'bg-re-green' :
msg.user.role === 'Current User' ? 'bg-blue-500' : msg.user.role === 'Current User' ? 'bg-blue-500' :
'bg-re-light-green' 'bg-re-light-green'
}`}> }`}>
{msg.user.avatar} {msg.user.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}> <div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground"> <div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
@ -222,7 +223,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
{msg.timestamp} {msg.timestamp}
</span> </span>
</div> </div>
<div <div
className="text-sm bg-muted/30 p-3 rounded-lg" className="text-sm bg-muted/30 p-3 rounded-lg"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -300,15 +301,14 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<h4 className="font-medium">Participants</h4> <h4 className="font-medium">Participants</h4>
<Badge variant="outline">{participants.length}</Badge> <Badge variant="outline">{participants.length}</Badge>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{participants.map((participant, index) => ( {participants.map((participant, index) => (
<div key={index} className="flex items-center gap-3"> <div key={index} className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback className={`text-white text-xs ${ <AvatarFallback className={`text-white text-xs ${participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green' }`}>
}`}>
{participant.avatar} {participant.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>

View File

@ -54,13 +54,13 @@ function ChartContainer({
<div <div
data-slot="chart" data-slot="chart"
data-chart={chartId} data-chart={chartId}
style={getChartStyle(config)}
className={cn( className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className, className,
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>
{children} {children}
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
@ -69,37 +69,39 @@ function ChartContainer({
); );
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const getChartStyle = (config: ChartConfig) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color, ([, config]) => config.theme || config.color,
); );
if (!colorConfig.length) { if (!colorConfig.length) {
return null; return {};
} }
return ( const styles: Record<string, string> = {};
<style
dangerouslySetInnerHTML={{ colorConfig.forEach(([key, itemConfig]) => {
__html: Object.entries(THEMES) // For simplicity, we'll use the default color or the light theme color
.map( // If you need per-theme variables, they should be handled via CSS classes or media queries
([theme, prefix]) => ` // but applying them here as inline styles is CSP-safe.
${prefix} [data-chart=${id}] { const color = itemConfig.color || itemConfig.theme?.light;
${colorConfig if (color) {
.map(([key, itemConfig]) => { styles[`--color-${key}`] = color;
const color = }
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color; // Handle dark theme if present
return color ? ` --color-${key}: ${color};` : null; const darkColor = itemConfig.theme?.dark;
}) if (darkColor) {
.join("\n")} styles[`--color-${key}-dark`] = darkColor;
} }
`, });
)
.join("\n"), return styles as React.CSSProperties;
}} };
/>
); // Deprecated: Kept for backward compatibility if needed in other files.
const ChartStyle = () => {
return null;
}; };
const ChartTooltip = RechartsPrimitive.Tooltip; const ChartTooltip = RechartsPrimitive.Tooltip;
@ -316,8 +318,8 @@ function getPayloadConfigFromPayload(
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload &&
typeof payload.payload === "object" && typeof payload.payload === "object" &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined; : undefined;

View File

@ -3,6 +3,7 @@ import { cn } from "./utils";
import { Button } from "./button"; import { Button } from "./button";
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react"; import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "./popover"; import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { sanitizeHTML } from "@/utils/sanitizer";
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> { interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
value: string; value: string;
@ -59,7 +60,8 @@ export function RichTextEditor({
// Only update if the value actually changed externally // Only update if the value actually changed externally
const currentValue = editorRef.current.innerHTML; const currentValue = editorRef.current.innerHTML;
if (currentValue !== value) { if (currentValue !== value) {
editorRef.current.innerHTML = value || ''; // Sanitize incoming content
editorRef.current.innerHTML = sanitizeHTML(value || '');
} }
} }
}, [value]); }, [value]);
@ -230,9 +232,9 @@ export function RichTextEditor({
selection.removeAllRanges(); selection.removeAllRanges();
selection.addRange(range); selection.addRange(range);
// Trigger onChange // Trigger onChange with sanitized content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
}, [onChange, cleanWordHTML]); }, [onChange, cleanWordHTML]);
@ -377,7 +379,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Check active formats after a short delay // Check active formats after a short delay
@ -529,7 +531,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
@ -633,7 +635,7 @@ export function RichTextEditor({
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
// Close popover // Close popover
@ -646,7 +648,7 @@ export function RichTextEditor({
// Handle input changes // Handle input changes
const handleInput = React.useCallback(() => { const handleInput = React.useCallback(() => {
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
checkActiveFormats(); checkActiveFormats();
}, [onChange, checkActiveFormats]); }, [onChange, checkActiveFormats]);
@ -682,7 +684,7 @@ export function RichTextEditor({
const handleBlur = React.useCallback(() => { const handleBlur = React.useCallback(() => {
setIsFocused(false); setIsFocused(false);
if (editorRef.current) { if (editorRef.current) {
onChange(editorRef.current.innerHTML); onChange(sanitizeHTML(editorRef.current.innerHTML));
} }
}, [onChange]); }, [onChange]);

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { sanitizeHTML } from '@/utils/sanitizer';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket'; import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter'; import { formatDateTime } from '@/utils/dateFormatter';
@ -8,12 +9,12 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
FileText, FileText,
Download, Download,
Clock, Clock,
Flag, Flag,
X, X,
@ -58,9 +59,11 @@ interface WorkNoteChatSimpleProps {
} }
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
return content const formattedContent = content
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>') .replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
.replace(/\n/g, '<br />'); .replace(/\n/g, '<br />');
return sanitizeHTML(formattedContent);
}; };
const FileIcon = ({ type }: { type: string }) => { const FileIcon = ({ type }: { type: string }) => {
@ -102,7 +105,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const filteredMessages = messages.filter(msg => const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) || msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@ -132,7 +135,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
// Realtime updates via Socket.IO // Realtime updates via Socket.IO
useEffect(() => { useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = requestId; let joinedId = requestId;
(async () => { (async () => {
try { try {
@ -140,39 +143,39 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (details?.workflow?.requestId) { if (details?.workflow?.requestId) {
joinedId = details.workflow.requestId; joinedId = details.workflow.requestId;
} }
} catch {} } catch { }
try { try {
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL) // Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
const s = getSocket(); // Uses getSocketBaseUrl() helper internally const s = getSocket(); // Uses getSocketBaseUrl() helper internally
joinRequestRoom(s, joinedId, currentUserId || undefined); joinRequestRoom(s, joinedId, currentUserId || undefined);
const noteHandler = (payload: any) => { const noteHandler = (payload: any) => {
const n = payload?.note || payload; const n = payload?.note || payload;
if (!n) return; if (!n) return;
setMessages(prev => { setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) { if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
return prev; return prev;
} }
const userName = n.userName || n.user_name || 'User'; const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role; const userRole = n.userRole || n.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = n.userId || n.user_id; const noteUserId = n.userId || n.user_id;
const newMsg = { const newMsg = {
id: n.noteId || n.note_id || String(Date.now()), id: n.noteId || n.note_id || String(Date.now()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: n.message || '', content: n.message || '',
timestamp: n.createdAt || n.created_at || new Date().toISOString(), timestamp: n.createdAt || n.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId isCurrentUser: noteUserId === currentUserId
} as any; } as any;
return [...prev, newMsg]; return [...prev, newMsg];
}); });
}; };
@ -189,7 +192,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} }
})(); })();
return () => { return () => {
try { (window as any).__wn_cleanup?.(); } catch {} try { (window as any).__wn_cleanup?.(); } catch { }
}; };
}, [requestId, currentUserId]); }, [requestId, currentUserId]);
@ -205,20 +208,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
}; };
}) : []; }) : [];
setMessages(mapped as any); setMessages(mapped as any);
} catch {} } catch { }
} }
setMessage(''); setMessage('');
setSelectedFiles([]); setSelectedFiles([]);
@ -234,21 +237,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || m.content || '', content: m.message || m.content || '',
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(), timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id, attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name, name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#', url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file', type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size fileSize: a.fileSize || a.file_size
@ -257,24 +260,24 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} as any; } as any;
}); });
setMessages(mapped); setMessages(mapped);
} catch {} } catch { }
} else { } else {
(async () => { (async () => {
try { try {
const rows = await getWorkNotes(requestId); const rows = await getWorkNotes(requestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => { const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
@ -336,7 +339,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (msg.id === messageId) { if (msg.id === messageId) {
const reactions = msg.reactions || []; const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji); const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) { if (existingReaction) {
if (existingReaction.users.includes('You')) { if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You'); existingReaction.users = existingReaction.users.filter(u => u !== 'You');
@ -349,7 +352,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} else { } else {
reactions.push({ emoji, users: ['You'] }); reactions.push({ emoji, users: ['You'] });
} }
return { ...msg, reactions }; return { ...msg, reactions };
} }
return msg; return msg;
@ -371,7 +374,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
@ -389,21 +392,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="space-y-6 max-w-full"> <div className="space-y-6 max-w-full">
{filteredMessages.map((msg) => { {filteredMessages.map((msg) => {
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You'; const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
return ( return (
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}> <div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && ( {!msg.isSystem && !isCurrentUser && (
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm"> <Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
<AvatarFallback className={`text-white font-semibold text-sm ${ <AvatarFallback className={`text-white font-semibold text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Initiator' ? 'bg-green-600' : msg.user.role === 'Approver' ? 'bg-blue-600' :
msg.user.role === 'Approver' ? 'bg-blue-600' : 'bg-slate-600'
'bg-slate-600' }`}>
}`}>
{msg.user.avatar} {msg.user.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
)} )}
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}> <div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full"> <div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
@ -435,7 +437,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
{/* Message Content */} {/* Message Content */}
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
<div <div
className="text-gray-800 leading-relaxed text-base" className="text-gray-800 leading-relaxed text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -449,72 +451,72 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const fileName = attachment.fileName || attachment.file_name || attachment.name; const fileName = attachment.fileName || attachment.file_name || attachment.name;
const fileType = attachment.fileType || attachment.file_type || attachment.type || ''; const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
const attachmentId = attachment.attachmentId || attachment.attachment_id; const attachmentId = attachment.attachmentId || attachment.attachment_id;
return ( return (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"> <div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<FileIcon type={fileType} /> <FileIcon type={fileType} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate"> <p className="text-sm font-medium text-gray-700 truncate">
{fileName} {fileName}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p> </p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</p>
)}
</div>
{/* Preview button for images and PDFs */}
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({
fileName,
fileType,
fileUrl: previewUrl,
fileSize,
attachmentId
});
}}
title="Preview file"
>
<Eye className="w-4 h-4" />
</Button>
)} )}
</div>
{/* Download button */}
{/* Preview button for images and PDFs */} <Button
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && ( variant="ghost"
<Button size="sm"
variant="ghost" className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
size="sm" onClick={async (e) => {
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({ if (!attachmentId) {
fileName, toast.error('Cannot download: Attachment ID missing');
fileType, return;
fileUrl: previewUrl, }
fileSize,
attachmentId try {
}); await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
}
}} }}
title="Preview file" title="Download file"
> >
<Eye className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
)} </div>
{/* Download button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (!attachmentId) {
toast.error('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
}
}}
title="Download file"
>
<Download className="w-4 h-4" />
</Button>
</div>
); );
})} })}
</div> </div>
@ -528,19 +530,18 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<button <button
key={index} key={index}
onClick={() => addReaction(msg.id, reaction.emoji)} onClick={() => addReaction(msg.id, reaction.emoji)}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${ className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
reaction.users.includes('You') ? 'bg-blue-100 text-blue-800 border border-blue-200'
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`} }`}
> >
<span>{reaction.emoji}</span> <span>{reaction.emoji}</span>
<span className="text-xs font-medium">{reaction.users.length}</span> <span className="text-xs font-medium">{reaction.users.length}</span>
</button> </button>
))} ))}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 flex-shrink-0" className="h-7 w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
> >
@ -552,7 +553,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
)} )}
</div> </div>
{!msg.isSystem && isCurrentUser && ( {!msg.isSystem && isCurrentUser && (
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm"> <Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-blue-500 text-white font-semibold text-sm"> <AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
@ -648,27 +649,27 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{/* Left side - Action buttons */} {/* Left side - Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={handleAttachmentClick} onClick={handleAttachmentClick}
title="Attach file" title="Attach file"
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Add emoji" title="Add emoji"
> >
<Smile className="h-4 w-4" /> <Smile className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setMessage(prev => prev + '@')} onClick={() => setMessage(prev => prev + '@')}
title="Mention someone" title="Mention someone"
@ -682,8 +683,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<span className="text-xs text-gray-500 whitespace-nowrap"> <span className="text-xs text-gray-500 whitespace-nowrap">
{message.length}/2000 {message.length}/2000
</span> </span>
<Button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={!message.trim() && selectedFiles.length === 0} disabled={!message.trim() && selectedFiles.length === 0}
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed" className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
size="sm" size="sm"
@ -695,7 +696,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* File Preview Modal */} {/* File Preview Modal */}
{previewFile && ( {previewFile && (
<FilePreview <FilePreview

View File

@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { FileText, AlertCircle } from 'lucide-react'; import { FileText, AlertCircle } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm'; import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { sanitizeHTML } from '@/utils/sanitizer';
interface AdminRequestReviewStepProps { interface AdminRequestReviewStepProps {
template: RequestTemplate; template: RequestTemplate;
@ -47,7 +48,7 @@ export function AdminRequestReviewStep({
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span> <span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
<div <div
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none" className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: formData.description }} dangerouslySetInnerHTML={{ __html: sanitizeHTML(formData.description) }}
/> />
</div> </div>

25
src/utils/sanitizer.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Sanitizes HTML content by removing dangerous attributes and tags.
* This is used to comply with CSP policies and prevent XSS.
*/
export function sanitizeHTML(html: string): string {
if (!html) return '';
// 1. Remove script tags completely
let sanitized = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// 2. Remove all "on*" event handler attributes (onclick, onload, etc.)
// This handles attributes like onclick="alert(1)" or onclick='alert(1)' or onclick=alert(1)
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:'[^']*'|"[^"]*"|[^\s>]+)/gi, '');
// 3. Remove "javascript:" pseudo-protocols in href or src
sanitized = sanitized.replace(/(href|src)\s*=\s*(?:'javascript:[^']*'|"javascript:[^"]*"|javascript:[^\s>]+)/gi, '$1="#"');
// 4. Remove <style> tags (to comply with style-src)
sanitized = sanitized.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove)
sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, '');
return sanitized;
}