diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 00000000..be4b59c1 --- /dev/null +++ b/frontend/src/components/ConfirmModal.tsx @@ -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 = ({ + isOpen, + title, + message, + onConfirm, + onCancel, + confirmText = 'Confirm', + cancelText = 'Cancel', + type = 'warning', +}) => { + const modalRef = useRef(null); + const confirmButtonRef = useRef(null); + const previousFocusRef = useRef(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 */} + + + {/* Confirm Modal */} + setModalState(prev => ({ ...prev, isOpen: false }))} + type={modalState.type} + /> ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 35d89d3b..850faab3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from '../components/ThemeContext' import { useToast } from '../components/ToastContext' +import { ConfirmModal } from '../components/ConfirmModal' function getSystemThemeForSettings(): string { if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') { @@ -22,8 +23,8 @@ const itemVariants = { const DEFAULT_CONFIG = { concurrentScans: 8, scanTimeout: 3600, - scanIntensity: 'standard', // 'low', 'standard', 'aggressive' - dataRetention: 30, // days + scanIntensity: 'standard', + dataRetention: 30, shodanKey: '', virustotalKey: '', ipWhitelist: '127.0.0.1\n10.0.0.0/8', @@ -56,6 +57,21 @@ export default function Settings() { const [systemTimezone, setSystemTimezone] = useState('Detecting...') + // 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", + }) + useEffect(() => { try { setSystemTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone) @@ -71,11 +87,32 @@ export default function Settings() { } const handleReset = () => { - if (window.confirm("Restore engine to factory specifications? All API keys and custom rules will be cleared.")) { - setConfig(DEFAULT_CONFIG) - localStorage.setItem('secuscan-config', JSON.stringify(DEFAULT_CONFIG)) - addToast("Engine parameters reset to factory defaults", "info") - } + setModalState({ + isOpen: true, + title: "Engine Reset", + message: "Restore engine to factory specifications? All API keys and custom rules will be cleared.", + type: "warning", + onConfirm: () => { + setConfig(DEFAULT_CONFIG) + localStorage.setItem('secuscan-config', JSON.stringify(DEFAULT_CONFIG)) + addToast("Engine parameters reset to factory defaults", "info") + setModalState(prev => ({ ...prev, isOpen: false })) + } + }) + } + + const handleNuclearPurge = () => { + setModalState({ + isOpen: true, + title: "NUCLEAR PURGE", + message: "CRITICAL: THIS WILL PURGE ALL HISTORY AND ASSETS. PROCEED?", + type: "danger", + onConfirm: () => { + localStorage.clear() + window.location.reload() + setModalState(prev => ({ ...prev, isOpen: false })) + } + }) } const handleExport = () => { @@ -142,7 +179,6 @@ export default function Settings() { return (
-
@@ -155,7 +191,6 @@ export default function Settings() { HARDWARE_TUNING // AUDIT_STRATEGY // SECTOR_ISOLATION

-
SYSTEM_TIMEZONE_SYNC @@ -163,10 +198,8 @@ export default function Settings() {
-
-

Engine_Parameters

@@ -212,7 +245,6 @@ export default function Settings() { />
-

Security_Interface

@@ -258,7 +290,6 @@ export default function Settings() {
-

Intelligence_API_Link

@@ -283,7 +314,6 @@ export default function Settings() { />
-

Access_Perimeters

@@ -302,7 +332,6 @@ export default function Settings() { />
-

Audit_Logic_Toggles

@@ -329,7 +358,6 @@ export default function Settings() { />
-
-
-

Engine_Status

@@ -392,7 +413,6 @@ export default function Settings() {
-
@@ -402,6 +422,14 @@ export default function Settings() { {[1,2,3,4,5,6,7,8].map(i =>
)}
+ setModalState(prev => ({ ...prev, isOpen: false }))} + type={modalState.type} + /> ) } diff --git a/frontend/testing/unit/components/ConfirmModal.test.tsx b/frontend/testing/unit/components/ConfirmModal.test.tsx new file mode 100644 index 00000000..d808cff2 --- /dev/null +++ b/frontend/testing/unit/components/ConfirmModal.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfirmModal } from '../../../src/components/ConfirmModal'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('ConfirmModal Accessibility', () => { + const defaultProps = { + isOpen: true, + title: 'Test Title', + message: 'Test Message', + onConfirm: vi.fn(), + onCancel: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('focuses on confirm button when modal opens', () => { + render(); + const confirmButton = screen.getByText('Confirm'); + expect(confirmButton).toHaveFocus(); + }); + + it('traps Tab key focus within modal', async () => { + const user = userEvent.setup(); + render(); + + const confirmButton = screen.getByText('Confirm'); + const cancelButton = screen.getByText('Cancel'); + + expect(confirmButton).toHaveFocus(); + + await user.tab(); + expect(cancelButton).toHaveFocus(); + + await user.tab(); + expect(confirmButton).toHaveFocus(); + }); + + it('closes with Escape key', () => { + render(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(defaultProps.onCancel).toHaveBeenCalled(); + }); + + it('has correct ARIA attributes', () => { + render(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(dialog).toHaveAttribute('aria-labelledby', 'modal-title'); + expect(dialog).toHaveAttribute('aria-describedby', 'modal-description'); + }); + + it('does not confirm with Enter when focus is on a button', async () => { + const user = userEvent.setup(); + render(); + + const cancelButton = screen.getByText('Cancel'); + await user.tab(); + expect(cancelButton).toHaveFocus(); + + fireEvent.keyDown(cancelButton, { key: 'Enter' }); + expect(defaultProps.onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/testing/unit/pages/SettingsSaveReset.test.tsx b/frontend/testing/unit/pages/SettingsSaveReset.test.tsx index 226e35d5..b50220a7 100644 --- a/frontend/testing/unit/pages/SettingsSaveReset.test.tsx +++ b/frontend/testing/unit/pages/SettingsSaveReset.test.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext' - const DEFAULT_CONFIG = { concurrentScans: 8, scanTimeout: 3600, @@ -22,7 +21,6 @@ const DEFAULT_CONFIG = { systemAlerts: true, }, } - function renderSettings() { render( @@ -32,7 +30,6 @@ function renderSettings() { , ) } - function getInputByLabelText(labelText: RegExp) { const label = screen.getByText(labelText) const card = label.closest('div')?.parentElement @@ -42,44 +39,35 @@ function getInputByLabelText(labelText: RegExp) { } return input as HTMLInputElement } - describe('Settings save/reset behavior', () => { beforeEach(() => { window.localStorage.removeItem('secuscan-config') vi.restoreAllMocks() }) - it('saves the current config to localStorage (secuscan-config)', async () => { const user = userEvent.setup() renderSettings() - const concurrentOps = getInputByLabelText(/Concurrent_Operations/i) fireEvent.change(concurrentOps, { target: { value: '3' } }) - await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) - const savedRaw = window.localStorage.getItem('secuscan-config') expect(savedRaw).toBeTruthy() const saved = JSON.parse(savedRaw as string) expect(saved.concurrentScans).toBe(3) }) - it('resets config to defaults after confirmation and persists it', async () => { window.localStorage.setItem( 'secuscan-config', JSON.stringify({ ...DEFAULT_CONFIG, concurrentScans: 2, shodanKey: 'abc' }), ) - - vi.spyOn(window, 'confirm').mockReturnValue(true) - const user = userEvent.setup() renderSettings() - await user.click(screen.getByRole('button', { name: /ENGINE_RESET/i })) - + // Confirm via the new modal instead of window.confirm + const confirmButton = await screen.findByRole('button', { name: /confirm/i }) + await user.click(confirmButton) const saved = JSON.parse(window.localStorage.getItem('secuscan-config') as string) expect(saved).toEqual(DEFAULT_CONFIG) - const concurrentOps = getInputByLabelText(/Concurrent_Operations/i) expect(concurrentOps.value).toBe('8') })