Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions frontend/src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
}

export const ConfirmModal: React.FC<ConfirmModalProps> = ({
isOpen,
title,
message,
onConfirm,
onCancel,
confirmText = 'Confirm',
cancelText = 'Cancel',
type = 'warning',
}) => {
const modalRef = useRef<HTMLDivElement>(null);
const confirmButtonRef = useRef<HTMLButtonElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);

useEffect(() => {
if (!isOpen) return;

// Store previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;

// Focus the confirm button
confirmButtonRef.current?.focus();

// Handle keyboard events
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
} else if (e.key === 'Enter' && !e.shiftKey) {
const activeElement = document.activeElement;
if (activeElement?.tagName !== 'BUTTON') {
e.preventDefault();
onConfirm();
}
} else if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

if (focusableElements && focusableElements.length > 0) {
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};

document.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
previousFocusRef.current?.focus();
};
}, [isOpen, onConfirm, onCancel]);

if (!isOpen) return null;

const getButtonStyle = () => {
switch (type) {
case 'danger': return 'bg-rag-red hover:bg-rag-red/80';
case 'warning': return 'bg-rag-amber hover:bg-rag-amber/80';
default: return 'bg-rag-blue hover:bg-rag-blue/80';
}
};

// Render modal using Portal - goes OUTSIDE #root
return createPortal(
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/60 z-40"
onClick={onCancel}
aria-hidden="true"
/>

{/* Modal - Neo-brutalist style */}
<div
ref={modalRef}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-50 w-full max-w-md"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<div className="bg-charcoal border-4 border-black shadow-[12px_12px_0px_0px_rgba(0,0,0,1)]">
{/* Header */}
<div className="border-b-4 border-black p-6">
<h2 id="modal-title" className="text-xl font-black uppercase tracking-wider text-silver-bright">
{title}
</h2>
</div>

{/* Body */}
<div className="p-6">
<p id="modal-description" className="text-sm font-mono text-silver-bright/70 leading-relaxed">
{message}
</p>
</div>

{/* Footer */}
<div className="border-t-4 border-black p-6 flex justify-end gap-4">
<button
onClick={onCancel}
className="px-6 py-3 bg-charcoal-dark border-2 border-black text-xs font-black uppercase tracking-wider text-silver-bright/60 hover:text-silver-bright hover:border-silver-bright/30 transition-all"
>
{cancelText}
</button>
<button
ref={confirmButtonRef}
onClick={onConfirm}
className={`px-6 py-3 ${getButtonStyle()} border-2 border-black text-black text-xs font-black uppercase tracking-wider shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-0.5 active:translate-y-0.5 transition-all`}
>
{confirmText}
</button>
</div>
</div>
</div>
</>,
document.body
);
};
134 changes: 82 additions & 52 deletions frontend/src/pages/Scans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
formatLocaleDate,
formatLocaleTime,
} from "../utils/date";
import { ConfirmModal } from "../components/ConfirmModal";
import Pagination from "../components/Pagination";

interface Task {
Expand Down Expand Up @@ -64,6 +65,21 @@ export default function Scans() {
const [total, setTotal] = useState(0);
const PAGE_LIMIT = 10;

// Modal state for confirm dialogs
const [modalState, setModalState] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
type: "danger" | "warning" | "info";
}>({
isOpen: false,
title: "",
message: "",
onConfirm: () => {},
type: "warning",
});

// Ref so the visibilitychange handler always sees the current interval id
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const requestSeqRef = useRef(0);
Expand Down Expand Up @@ -167,64 +183,68 @@ export default function Scans() {
}

async function handleTaskDelete(taskId: string) {
if (
!window.confirm(
"Are you sure you want to delete this scan record? This will also remove associated findings and reports.",
)
) {
return;
}

try {
await deleteTask(taskId);
setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
if (expandedId === taskId) setExpandedId(null);
} catch (err) {
console.error("Failed to delete task:", err);
alert("Failed to delete task. It might still be running.");
}
setModalState({
isOpen: true,
title: "Delete Scan Record",
message: "Are you sure you want to delete this scan record? This will also remove associated findings and reports.",
type: "danger",
onConfirm: async () => {
try {
await deleteTask(taskId);
setTasks((prev) => prev.filter((t) => t.task_id !== taskId));
if (expandedId === taskId) setExpandedId(null);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to delete task:", err);
alert("Failed to delete task. It might still be running.");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
});
}

async function handleClearAll() {
if (
!window.confirm(
"CRITICAL: Are you sure you want to PURGE ALL RECORDS? This will wipe all scan history, findings, assets, and reports. This action is irreversible.",
)
) {
return;
}

try {
await clearAllTasks();
setTasks([]);
setSelectedIds([]);
setExpandedId(null);
} catch (err) {
console.error("Failed to clear history:", err);
alert("Failed to clear history. Ensure no tasks are currently running.");
}
setModalState({
isOpen: true,
title: "CRITICAL OPERATION",
message: "CRITICAL: Are you sure you want to PURGE ALL RECORDS? This will wipe all scan history, findings, assets, and reports. This action is irreversible.",
type: "danger",
onConfirm: async () => {
try {
await clearAllTasks();
setTasks([]);
setSelectedIds([]);
setExpandedId(null);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Failed to clear history:", err);
alert("Failed to clear history. Ensure no tasks are currently running.");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
});
}

async function handleBulkDelete() {
if (selectedIds.length === 0) return;
if (
!window.confirm(
`Are you sure you want to delete ${selectedIds.length} selected scan records?`,
)
) {
return;
}

try {
await bulkDeleteTasks(selectedIds);
setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id)));
setSelectedIds([]);
} catch (err) {
console.error("Bulk delete failed:", err);
alert(
"Failed to delete some tasks. Ensure they are not currently running.",
);
}
setModalState({
isOpen: true,
title: "Bulk Delete Records",
message: `Are you sure you want to delete ${selectedIds.length} selected scan records?`,
type: "danger",
onConfirm: async () => {
try {
await bulkDeleteTasks(selectedIds);
setTasks((prev) => prev.filter((t) => !selectedIds.includes(t.task_id)));
setSelectedIds([]);
setModalState(prev => ({ ...prev, isOpen: false }));
} catch (err) {
console.error("Bulk delete failed:", err);
alert("Failed to delete some tasks. Ensure they are not currently running.");
setModalState(prev => ({ ...prev, isOpen: false }));
}
},
});
}

function toggleSelection(taskId: string, e: React.MouseEvent) {
Expand Down Expand Up @@ -677,6 +697,16 @@ export default function Scans() {
))}
</div>
</footer>

{/* Confirm Modal */}
<ConfirmModal
isOpen={modalState.isOpen}
title={modalState.title}
message={modalState.message}
onConfirm={modalState.onConfirm}
onCancel={() => setModalState(prev => ({ ...prev, isOpen: false }))}
type={modalState.type}
/>
</div>
);
}
}
Loading
Loading