diff --git a/ui/src/components/custom/matrixLoader.tsx b/ui/src/components/custom/matrixLoader.tsx index 23ddb49..2a066a3 100644 --- a/ui/src/components/custom/matrixLoader.tsx +++ b/ui/src/components/custom/matrixLoader.tsx @@ -3,7 +3,13 @@ import axios from "axios"; import { useMultiMatrix } from "./multiMatrixProvider"; const MatrixLoader: React.FC = () => { - const { setMultiMatrix, setConfig, setItemIdMap, setRawItems } = useMultiMatrix(); + const { + setMultiMatrix, + setConfig, + setItemIdMap, + setRawItems, + setItemImages + } = useMultiMatrix(); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -61,6 +67,15 @@ const MatrixLoader: React.FC = () => { // Save raw items into provider (we'll let the UI decide which ones to display) setRawItems(matrixData.items); + // Load images data into the provider context if available + if (matrixData.images && Array.isArray(matrixData.images)) { + console.log("Loading images into context:", matrixData.images.length); + setItemImages(matrixData.images); + } else { + console.log("No images found in matrix data"); + setItemImages([]); + } + // Set the config, including hosts, participants, and the current user's email. setConfig({ r2Multi: matrixData.r2Multi, @@ -81,8 +96,12 @@ const MatrixLoader: React.FC = () => { // For backwards compatibility (when roleBased is false), apply filtering based on randomAssignment. let processedItems = matrixData.items; - if (!matrixData.roleBased) { - processedItems = matrixData.items; + if (!matrixData.roleBased && matrixData.randomAssignment) { + // When role restrictions are disabled but random assignment is enabled, + // only show items where the current user is in targetUsers + processedItems = matrixData.items.filter((item: any) => + Array.isArray(item.targetUsers) && item.targetUsers.includes(currentUserEmail) + ); } // Build a matrix map and id map from the processed items @@ -114,7 +133,7 @@ const MatrixLoader: React.FC = () => { .catch((error) => { console.error("Error fetching user info:", error); }); - }, [setMultiMatrix, setConfig, setItemIdMap, setRawItems]); + }, [setMultiMatrix, setConfig, setItemIdMap, setRawItems, setItemImages]); return null; }; diff --git a/ui/src/components/custom/multiMatrixProvider.tsx b/ui/src/components/custom/multiMatrixProvider.tsx index 2bea341..2211759 100644 --- a/ui/src/components/custom/multiMatrixProvider.tsx +++ b/ui/src/components/custom/multiMatrixProvider.tsx @@ -2,6 +2,13 @@ import React, { createContext, useContext, useState, ReactNode } from "react"; export type MultiMatrix = Map>; +// Add interface for image data +export interface MatrixImage { + itemId: number; + imageId: number; + imageUrl: string; +} + export type ConfigType = { r2Multi: number; randomAssignment: boolean; @@ -32,6 +39,13 @@ export type MultiMatrixContextType = { // New rawItems state to store the full items list from the API: rawItems: any[]; setRawItems: React.Dispatch>; + // New state for storing image data: + itemImages: MatrixImage[]; + setItemImages: React.Dispatch>; + // Helper function to check if an item has images + hasItemImages: (itemId: number) => boolean; + // Helper function to get images for a specific item + getItemImages: (itemId: number) => MatrixImage[]; }; const initialMultiMatrix: MultiMatrix = new Map([ @@ -103,6 +117,17 @@ export const MultiMatrixProvider: React.FC<{ children: ReactNode }> = ({ childre const [itemIdMap, setItemIdMap] = useState>(new Map()); const [updates, setUpdates] = useState([]); const [rawItems, setRawItems] = useState([]); + const [itemImages, setItemImages] = useState([]); + + // Helper function to check if an item has associated images + const hasItemImages = (itemId: number): boolean => { + return itemImages.some(img => Number(img.itemId) === Number(itemId)); + }; + + // Helper function to get all images for a specific item + const getItemImages = (itemId: number): MatrixImage[] => { + return itemImages.filter(img => Number(img.itemId) === Number(itemId)); + }; const contextValue: MultiMatrixContextType = { multiMatrix, @@ -115,6 +140,10 @@ export const MultiMatrixProvider: React.FC<{ children: ReactNode }> = ({ childre setUpdates, rawItems, setRawItems, + itemImages, + setItemImages, + hasItemImages, + getItemImages, }; return ( diff --git a/ui/src/pages/CreateMatrix.tsx b/ui/src/pages/CreateMatrix.tsx index 33e0645..c6cc68c 100644 --- a/ui/src/pages/CreateMatrix.tsx +++ b/ui/src/pages/CreateMatrix.tsx @@ -20,17 +20,18 @@ import { Alert, Tooltip, CircularProgress, + Modal, + IconButton, } from "@mui/material"; import { useState } from "react"; -import { IconButton } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import { whoamiUpsert, createMatrix } from "./apiService"; import SendIcon from "@mui/icons-material/Send"; import { useNavigate } from "react-router-dom"; import GroupsIcon from '@mui/icons-material/Groups'; import PlaceIcon from '@mui/icons-material/Place'; - - +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; export const CreateMatrix: React.FC = () => { const [RoleBasedChecked, setRoleBasedChecked] = useState(true); @@ -48,6 +49,10 @@ export const CreateMatrix: React.FC = () => { const [participantsData, setParticipantsData] = useState< { email: string; role: string }[] >([]); + const [targetImages, setTargetImages] = useState<{ [key: number]: string[] }>({}); + const [openImageModal, setOpenImageModal] = useState(false); + const [currentTargetIndex, setCurrentTargetIndex] = useState(null); + const [dragActive, setDragActive] = useState(false); const initialMultipliers = { Criticality: 1.0, @@ -166,6 +171,71 @@ export const CreateMatrix: React.FC = () => { setValue(event.target.value as number); }; + const handleImageUpload = (index: number) => { + setCurrentTargetIndex(index); + setOpenImageModal(true); + }; + + const handleCloseImageModal = () => { + setOpenImageModal(false); + setCurrentTargetIndex(null); + }; + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + const files = Array.from(e.dataTransfer.files); + handleFiles(files); + }; + + const handleFileInput = (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + handleFiles(files); + } + }; + + const handleFiles = async (files: File[]) => { + if (currentTargetIndex === null) return; + + const imageFiles = files.filter(file => file.type.startsWith('image/')); + const base64Images = await Promise.all( + imageFiles.map(file => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + }; + reader.readAsDataURL(file); + }); + }) + ); + + setTargetImages(prev => ({ + ...prev, + [currentTargetIndex]: [...(prev[currentTargetIndex] || []), ...base64Images] + })); + }; + + const handleRemoveImage = (targetIndex: number, imageIndex: number) => { + setTargetImages(prev => ({ + ...prev, + [targetIndex]: prev[targetIndex].filter((_, i) => i !== imageIndex) + })); + }; + const handleCreateMatrix = async () => { try { // Duplicate check for targets @@ -200,14 +270,15 @@ export const CreateMatrix: React.FC = () => { const parsedFirstObject = JSON.parse(firstObjectStr); const { userId, email } = parsedFirstObject; - const items = targets.map((target) => ({ + const items = targets.map((target, index) => ({ itemName: target, criticality: {}, accessibility: {}, recoverability: {}, vulnerability: {}, effect: {}, - recognizability: {} + recognizability: {}, + images: targetImages[index] || [] })); const hosts = [ @@ -785,7 +856,6 @@ export const CreateMatrix: React.FC = () => { value={target} onChange={(e) => handleTargetChange(index, e)} variant="standard" - fullWidth placeholder="Enter target..." InputProps={{ disableUnderline: true, @@ -795,7 +865,7 @@ export const CreateMatrix: React.FC = () => { fontWeight: "500", }, }} - sx={{ + sx={{ flexGrow: 1, '& .MuiInputBase-root': { padding: "4px 0", @@ -806,20 +876,32 @@ export const CreateMatrix: React.FC = () => { }, }} /> - handleDeleteTarget(index)} - sx={{ - color: "rgba(255, 255, 255, 0.5)", - padding: "6px", - transition: "all 0.2s ease", - '&:hover': { - color: "#ff4444", - backgroundColor: "rgba(255, 68, 68, 0.08)", - }, - }} - > - - + + + handleImageUpload(index)} + sx={{ + color: targetImages[index]?.length ? '#014093' : '#ffffff', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }} + > + + + + handleDeleteTarget(index)} + sx={{ + color: '#ffffff', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + }} + > + + + ))} @@ -1152,6 +1234,109 @@ export const CreateMatrix: React.FC = () => { Must have at least one user with Participant role + + {/* Image Upload Modal */} + + + + Upload Images + + + document.getElementById('file-input')?.click()} + > + + + + Drag and drop images here or click to select + + + + {currentTargetIndex !== null && targetImages[currentTargetIndex] && ( + + + Uploaded Images: + + + {targetImages[currentTargetIndex].map((image, idx) => ( + + {`Uploaded + handleRemoveImage(currentTargetIndex, idx)} + > + + + + ))} + + + )} + + + + + + ); }; diff --git a/ui/src/pages/EditMatrix.tsx b/ui/src/pages/EditMatrix.tsx index 93b84a4..5da3ba6 100644 --- a/ui/src/pages/EditMatrix.tsx +++ b/ui/src/pages/EditMatrix.tsx @@ -1,4 +1,6 @@ +console.log("EditMatrix.tsx module is being loaded"); import React, { useState, useMemo, useEffect } from "react"; +import { useLocation } from "react-router-dom"; import { Box, Typography, @@ -19,13 +21,16 @@ import { LinearProgress, Chip, CircularProgress, + Modal, } from "@mui/material"; import MatrixExplorer from "../components/custom/search/matrixExplorer"; import CategoryGroup from "../components/custom/editMatrix/categoryGroup"; import { MultiMatrixProvider, useMultiMatrix, + MatrixImage } from "../components/custom/multiMatrixProvider"; +console.log("MultiMatrixProvider imported:", !!MultiMatrixProvider, "useMultiMatrix imported:", !!useMultiMatrix); import MatrixLoader from "../components/custom/matrixLoader"; import { ExportPdfButton } from "../components/custom/pdfExport/ExportPdfButton"; import axios from "axios"; @@ -35,11 +40,30 @@ import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import GroupsIcon from '@mui/icons-material/Groups'; import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; +import AttachFileIcon from '@mui/icons-material/AttachFile'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import DownloadIcon from '@mui/icons-material/Download'; +import CloseIcon from '@mui/icons-material/Close'; + +interface MatrixItem { + itemId: number; + itemName: string; + criticality: Record; + accessibility: Record; + recoverability: Record; + vulnerability: Record; + effect: Record; + recognizability: Record; + createdAt: string; + targetUsers: any[]; + images: MatrixImage[] | null; + [key: string]: any; +} // Host Pane Component const HostPane: React.FC<{ config: any; - items: any[]; + items: MatrixItem[]; categories: string[]; }> = ({ config, items, categories }) => { // Add category mapping for correct property access @@ -121,7 +145,7 @@ const HostPane: React.FC<{ }, [categoryCompletions, categories]); // Calculate average scores with multipliers for the matrix view - const getAverageScore = (item: any, category: string): number => { + const getAverageScore = (item: MatrixItem, category: string): number => { const key = categoryToPropertyMap[category]; const scores = item[key] || {}; const values = Object.values(scores) as number[]; @@ -377,11 +401,34 @@ const HostPane: React.FC<{ }; const EditMatrixContent: React.FC = () => { - const { config, rawItems, updates } = useMultiMatrix(); + const location = useLocation(); + + const { + config, + rawItems, + updates, + hasItemImages, + getItemImages + } = useMultiMatrix(); + const currentEmail = config.currentUserEmail; + const isRoleBased = config.roleBased; + const isHost = isRoleBased && currentEmail ? config.hosts?.includes(currentEmail) : false; + const isParticipant = isRoleBased && currentEmail ? config.participants?.includes(currentEmail) : false; + const [successToast, setSuccessToast] = useState(false); const [errorToast, setErrorToast] = useState(false); - + const [openImageModal, setOpenImageModal] = useState(false); + const [selectedItemImages, setSelectedItemImages] = useState([]); + const [fullViewImage, setFullViewImage] = useState(null); + const [activeView, setActiveView] = useState<'host' | 'participant'>( + isRoleBased && currentEmail + ? isHost && !isParticipant + ? 'host' + : 'participant' + : 'participant' + ); + const categoriesConst = [ "Criticality", "Accessibility", @@ -411,23 +458,6 @@ const EditMatrixContent: React.FC = () => { "Recognizability": "How easily the target can be identified. Higher values mean the target is more recognizable and requires less preparation to identify.", }; - // Determine user roles if roleBased is enabled. - const isRoleBased = config.roleBased; - const isHost = - isRoleBased && currentEmail ? config.hosts?.includes(currentEmail) : false; - const isParticipant = - isRoleBased && currentEmail ? config.participants?.includes(currentEmail) : false; - - const [activeView, setActiveView] = useState<'host' | 'participant'>(() => { - // Set initial view based on user role - if (isRoleBased && currentEmail) { - if (isHost && !isParticipant) return 'host'; - if (isParticipant && !isHost) return 'participant'; - return 'participant'; // Default for users with both roles - } - return 'participant'; // Default for non-role-based matrices - }); - // Force view to host if user is only a host useEffect(() => { if (isRoleBased && isHost && !isParticipant) { @@ -448,7 +478,7 @@ const EditMatrixContent: React.FC = () => { if (config.randomAssignment) { // For random assignment, use targetUsers return rawItems.filter( - (item: any) => + (item: MatrixItem) => Array.isArray(item.targetUsers) && item.targetUsers.includes(currentEmail) ); } else { @@ -461,7 +491,7 @@ const EditMatrixContent: React.FC = () => { if (config.randomAssignment) { // For random assignment, use targetUsers return rawItems.filter( - (item: any) => + (item: MatrixItem) => Array.isArray(item.targetUsers) && item.targetUsers.includes(currentEmail) ); } else { @@ -473,19 +503,20 @@ const EditMatrixContent: React.FC = () => { } } } - // Not roleBased: show all items + // Not roleBased: still respect random assignment if enabled + if (config.randomAssignment) { + return rawItems.filter( + (item: MatrixItem) => + Array.isArray(item.targetUsers) && item.targetUsers.includes(currentEmail) + ); + } return rawItems; }, [isRoleBased, isHost, isParticipant, rawItems, currentEmail, activeView, config.randomAssignment, config.participants]); - // Debugging: log when displayedItems changes. - useEffect(() => { - console.log("Displayed items count:", displayedItems.length); - }, [displayedItems]); - // Build matrix map and sorted target names. const matrixMap = useMemo(() => { const map = new Map>(); - displayedItems.forEach((item: any) => { + displayedItems.forEach((item: MatrixItem) => { const categoryMap = new Map(); categories.forEach(category => { const key = categoryToPropertyMap[category]; @@ -503,11 +534,15 @@ const EditMatrixContent: React.FC = () => { return Array.from(matrixMap.keys()).sort((a, b) => a.localeCompare(b)); }, [matrixMap]); + // Get matrix ID from URL + const getMatrixId = () => { + const params = new URLSearchParams(location.search); + return params.get("matrixId"); + }; + const handleSubmitUpdates = () => { - const params = new URLSearchParams(window.location.search); - const matrixId = params.get("matrixId"); + const matrixId = getMatrixId(); if (!matrixId) { - console.error("matrixId query parameter is missing."); setErrorToast(true); return; } @@ -528,20 +563,16 @@ const EditMatrixContent: React.FC = () => { return formattedUpdate; }); - console.log("Sending formatted updates:", formattedUpdates); - axios .put(`/api/carvermatrices/${matrixId}/carveritems/update`, formattedUpdates) - .then((response) => { - console.log("Updates submitted successfully", response); + .then(() => { setSuccessToast(true); - // Add 1.5 second delay before refreshing + // Add delay before refreshing setTimeout(() => { window.location.reload(); }, 1500); }) - .catch((error) => { - console.error("Error submitting updates", error); + .catch(() => { setErrorToast(true); }); }; @@ -554,6 +585,79 @@ const EditMatrixContent: React.FC = () => { setActiveView(newValue); }; + const handleImageClick = (itemId: number) => { + // Use the provider function to get images for this item + const images = getItemImages(itemId); + setSelectedItemImages(images); + setOpenImageModal(true); + }; + + const handleCloseImageModal = () => { + setOpenImageModal(false); + setSelectedItemImages([]); + setFullViewImage(null); + }; + + const handleDownloadImage = (image: MatrixImage) => { + // Create a temporary anchor element + const link = document.createElement('a'); + link.href = image.imageUrl; + // Extract filename from URL or use a default name + const filename = image.imageUrl.split('/').pop() || `target-image-${image.imageId}.jpg`; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleOpenFullView = (image: MatrixImage) => { + setFullViewImage(image); + }; + + const handleCloseFullView = () => { + setFullViewImage(null); + }; + + // Render target cell with paperclip icon if images are available + const renderTargetCell = (target: string) => { + const item = displayedItems.find(item => item.itemName === target); + const itemId = item ? Number(item.itemId) : null; + + // Check if this target has any images using the provider function + const hasImages = itemId !== null && hasItemImages(itemId); + + return ( + + + {target} + {hasImages && ( + + itemId && handleImageClick(itemId)} + sx={{ + color: '#014093', + '&:hover': { + backgroundColor: 'rgba(1, 64, 147, 0.1)', + }, + }} + > + + + + )} + + + ); + }; + return ( { {targets.map((target) => ( - - {target} - + {renderTargetCell(target)} {categories.map((category) => { const item = displayedItems.find(item => item.itemName === target); const key = categoryToPropertyMap[category]; @@ -987,6 +1083,221 @@ const EditMatrixContent: React.FC = () => { + + {/* Image Display Modal */} + + + + Target Images ({selectedItemImages.length}) + + ✕ + + + + {selectedItemImages.length === 0 ? ( + + No images available for this target. + + ) : ( + + {selectedItemImages.map((image, index) => ( + + + {`Target + + + handleOpenFullView(image)} + sx={{ + color: '#fff', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + }} + > + + + + + handleDownloadImage(image)} + sx={{ + color: '#fff', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + }} + > + + + + + + + ))} + + )} + + + + {/* Full View Modal */} + + + + + + + + + {fullViewImage && ( + Full size target image + )} + + {fullViewImage && ( + + + handleDownloadImage(fullViewImage)} + sx={{ + color: '#fff', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + }} + > + + + + + )} + + ); };