changes made to sanitize html to overcome the VAPT alets
This commit is contained in:
parent
81565d294b
commit
d285ea88d8
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
|||||||
@ -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
25
src/utils/sanitizer.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user