diff --git a/frontend/src/components/AuthContext.tsx b/frontend/src/components/AuthContext.tsx index 985e8ce33..e8b8120f5 100644 --- a/frontend/src/components/AuthContext.tsx +++ b/frontend/src/components/AuthContext.tsx @@ -52,42 +52,4 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { let cancelled = false checkAuthSession().then((authenticated) => { - if (!cancelled) { - setIsAuthenticated(authenticated) - setLoading(false) - } - }) - return () => { - cancelled = true - } - }, []) - - // A 401 anywhere invalidates the session. - useEffect(() => { - function onAuthRequired() { - setIsAuthenticated(false) - } - window.addEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) - return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired) - }, []) - - const markAuthenticated = useCallback(() => { - setIsAuthenticated(true) - }, []) - - const signOut = useCallback(async () => { - await logoutSession() - setIsAuthenticated(false) - }, []) - - const value = useMemo( - () => ({ isAuthenticated, loading, markAuthenticated, signOut }), - [isAuthenticated, loading, markAuthenticated, signOut], - ) - - return {children} -} - -export function useAuth(): AuthContextValue { - return useContext(AuthContext) -} + .catch(err => console.error(err)) \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 1f43fadaa..9b8efc83a 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -250,1278 +250,4 @@ export default function Findings() { setLoading(true) getFindings(1, perPage) .then((data: FindingsResponse) => { - const nextFindings = (data.findings || []).filter( - (finding) => typeof finding.id === 'string', - ) as Finding[] - setFindings(nextFindings) - setTotalItems(data.total ?? nextFindings.length) - setPage(1) - setSelectedFindingId((current) => current ?? nextFindings[0]?.id ?? null) - }) - .finally(() => setLoading(false)) - }, []) - - useEffect(() => { - try { - const saved = localStorage.getItem('secuscan-finding-review-state') - if (saved) { - setReviewState(JSON.parse(saved)) - } - } catch { - // Ignore malformed local review state. - } - }, []) - - useEffect(() => { - localStorage.setItem('secuscan-finding-review-state', JSON.stringify(reviewState)) - }, [reviewState]) - - const enrichedFindings = useMemo( - () => - findings.map((finding) => ({ - ...finding, - severity: normalizeSeverity(finding.severity), - status: reviewState[finding.id] || ( - finding.analyst_status === 'confirmed' - ? 'reviewed' - : finding.analyst_status === 'false_positive' - ? 'suppressed' - : 'new' - ), - })), - [findings, reviewState], - ) - - // Collect unique targets and categories so we can build filter dropdowns. - const uniqueTargets = useMemo(() => { - const seen = new Set() - for (const f of enrichedFindings) { - if (f.target) seen.add(f.target) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - // plugin_id values serve as the "scanner/tool" filter per issue #43 - const uniqueScanners = useMemo(() => { - const seen = new Set() - for (const f of enrichedFindings) { - if (f.plugin_id) seen.add(f.plugin_id) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - const uniqueAssets = useMemo(() => { - const seen = new Set() - for (const finding of enrichedFindings) { - const label = finding.asset_id || finding.asset_refs?.[0] || finding.target - if (label) seen.add(label) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - const uniqueKinds = useMemo(() => { - const seen = new Set() - for (const finding of enrichedFindings) { - if (finding.finding_kind) seen.add(finding.finding_kind) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - const uniqueAnalystStatuses = useMemo(() => { - const seen = new Set() - for (const finding of enrichedFindings) { - if (finding.analyst_status) seen.add(finding.analyst_status) - } - return Array.from(seen).sort() - }, [enrichedFindings]) - - const filteredFindings = useMemo(() => { - const query = searchQuery.trim().toLowerCase() - - const tz = getCurrentTimeZone() - const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz }) - - return enrichedFindings.filter((finding) => { - const matchesSeverity = filterSeverity === 'all' || finding.severity === filterSeverity - const matchesTarget = filterTarget === 'all' || finding.target === filterTarget - const matchesScanner = filterScanner === 'all' || finding.plugin_id === filterScanner - const assetLabel = finding.asset_id || finding.asset_refs?.[0] || finding.target - const matchesAsset = filterAsset === 'all' || assetLabel === filterAsset - const matchesKind = filterKind === 'all' || finding.finding_kind === filterKind - const matchesAnalystStatus = filterAnalystStatus === 'all' || finding.analyst_status === filterAnalystStatus - const matchesValidated = !filterValidatedOnly || Boolean(finding.validated) - const matchesHighConfidence = !filterHighConfidence || Number(finding.confidence || 0) >= 0.75 - - if (dateFrom || dateTo) { - const parsed = parseDateSafe(finding.discovered_at) - if (!parsed) return false - const displayDay = dateFormatter.format(parsed) - if (dateFrom && displayDay < dateFrom) return false - if (dateTo && displayDay > dateTo) return false - } - - const haystack = [ - finding.title, - finding.target, - finding.description, - finding.remediation, - finding.cve, - finding.category, - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - - return ( - matchesSeverity && - matchesTarget && - matchesScanner && - matchesAsset && - matchesKind && - matchesAnalystStatus && - matchesValidated && - matchesHighConfidence && - haystack.includes(query) - ) - }) - }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, searchQuery, dateFrom, dateTo]) - - // ── Multi-select export state & handlers ─────────────────────────────────── - const visibleIds = useMemo(() => filteredFindings.map((f) => f.id), [filteredFindings]) - const isAllSelected = useMemo(() => { - if (visibleIds.length === 0) return false - return visibleIds.every((id) => selectedIds.has(id)) - }, [visibleIds, selectedIds]) - - const handleSelectAllToggle = () => { - if (isAllSelected) { - setSelectedIds((prev) => { - const next = new Set(prev) - visibleIds.forEach((id) => next.delete(id)) - return next - }) - } else { - setSelectedIds((prev) => { - const next = new Set(prev) - visibleIds.forEach((id) => next.add(id)) - return next - }) - } - } - - const handleCheckboxChange = (id: string, checked: boolean) => { - setSelectedIds((prev) => { - const next = new Set(prev) - if (checked) { - next.add(id) - } else { - next.delete(id) - } - return next - }) - } - - const handleExportCSV = () => { - const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) - exportFindingsAsCSV(selectedFindings) - } - - const handleExportJSON = () => { - const selectedFindings = findings.filter((f) => selectedIds.has(f.id)) - exportFindingsAsJSON(selectedFindings) - } - - const sortedFindings = useMemo(() => { - const items = [...filteredFindings] - switch (sortMode) { - case 'risk': - return items.sort((a, b) => { - const ra = a.risk_score ?? 0 - const rb = b.risk_score ?? 0 - return rb - ra - }) - case 'newest': - return items.sort((a, b) => { - const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 - const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 - return db - da - }) - case 'oldest': - return items.sort((a, b) => { - const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 - const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 - return da - db - }) - case 'target': - return items.sort((a, b) => - (a.target || '').localeCompare(b.target || '') - ) - case 'severity': - default: - return items - } - }, [filteredFindings, sortMode]) - - // Build the flat virtual row list: header + findings per severity group - // For non-severity sort modes, all findings appear in a single flat list - const virtualRows = useMemo(() => { - const rows: VirtualRow[] = [] - if (sortMode === 'severity') { - for (const severity of severityOrder) { - const items = filteredFindings.filter((f) => f.severity === severity) - if (items.length === 0) continue - rows.push({ kind: 'header', severity, count: items.length }) - items.forEach((finding, idx) => { - rows.push({ - kind: 'finding', - finding, - isLastInGroup: idx === items.length - 1, - }) - }) - } - } else { - // For newest/oldest/target sort — single flat list, no headers - sortedFindings.forEach((finding, idx) => { - rows.push({ - kind: 'finding', - finding, - isLastInGroup: idx === sortedFindings.length - 1, - }) - }) - } - return rows - }, [filteredFindings, sortedFindings, sortMode]) - - const countsBySeverity = useMemo(() => { - return severityOrder.reduce>((acc, severity) => { - acc[severity] = enrichedFindings.filter((finding) => finding.severity === severity).length - return acc - }, {}) - }, [enrichedFindings]) - - const triageMetrics = useMemo( - () => ({ - total: enrichedFindings.length, - visible: filteredFindings.length, - active: countsBySeverity.critical + countsBySeverity.high, - unresolved: enrichedFindings.filter((finding) => finding.status === 'new').length, - }), - [enrichedFindings, filteredFindings, countsBySeverity], - ) - - const selectedFinding = - sortedFindings.find((finding) => finding.id === selectedFindingId) ?? - sortedFindings[0] ?? - null - - useEffect(() => { - if (!selectedFinding) { - setSelectedFindingId(null) - return - } - if (!sortedFindings.some((finding) => finding.id === selectedFinding.id)) { - setSelectedFindingId(sortedFindings[0]?.id ?? null) - } - }, [sortedFindings, selectedFinding]) - - // Derives a flat list of active filter chips from non-default filter state. - const activeFilters = useMemo(() => { - const chips: { key: string; label: string }[] = [] - if (searchQuery.trim()) chips.push({ key: 'search', label: `Search: "${searchQuery.trim()}"` }) - if (filterTarget !== 'all') chips.push({ key: 'target', label: `Target: ${filterTarget}` }) - if (filterScanner !== 'all') chips.push({ key: 'scanner', label: `Scanner: ${filterScanner}` }) - if (filterAsset !== 'all') chips.push({ key: 'asset', label: `Asset: ${filterAsset}` }) - if (filterKind !== 'all') chips.push({ key: 'kind', label: `Kind: ${filterKind}` }) - if (filterAnalystStatus !== 'all') chips.push({ key: 'analyst', label: `Analyst: ${filterAnalystStatus}` }) - if (filterValidatedOnly) chips.push({ key: 'validated', label: 'Validated Only' }) - if (filterHighConfidence) chips.push({ key: 'confidence', label: 'High Confidence' }) - if (sortMode !== 'risk') chips.push({ key: 'sort', label: `Sort: ${sortMode}` }) - if (dateFrom) chips.push({ key: 'from', label: `From: ${dateFrom}` }) - if (dateTo) chips.push({ key: 'to', label: `To: ${dateTo}` }) - return chips - }, [searchQuery, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, sortMode, dateFrom, dateTo]) - - function resetAllFilters() { - setFilterSeverity('all') - setFilterTarget('all') - setFilterScanner('all') - setFilterAsset('all') - setFilterKind('all') - setFilterAnalystStatus('all') - setFilterValidatedOnly(false) - setFilterHighConfidence(false) - setSortMode('risk') - setDateFrom('') - setDateTo('') - setSearchQuery('') - setSelectedIds(new Set()) - } - - function updateFindingStatus(id: string, status: FindingStatus) { - setReviewState((current) => ({ ...current, [id]: status })) - } - - async function copyFindingSummary(finding: Finding & { status: FindingStatus }) { - const summary = [ - `${finding.title} (${finding.severity.toUpperCase()})`, - `Target: ${finding.target || 'N/A'}`, - `Category: ${finding.category || 'Uncategorized'}`, - finding.cve ? `CVE: ${finding.cve}` : null, - `Status: ${finding.status.toUpperCase()}`, - `Observed: ${formatLocaleDate(finding.discovered_at)}`, - `Description: ${finding.description || 'No description provided.'}`, - `Remediation: ${finding.remediation || 'No remediation provided.'}`, - ] - .filter(Boolean) - .join('\n') - - try { - await navigator.clipboard.writeText(summary) - setCopiedFindingId(finding.id) - window.setTimeout(() => setCopiedFindingId((current) => (current === finding.id ? null : current)), 1600) - } catch { - setCopiedFindingId(null) - } - } - async function copyFindingId(findingId: string) { - try { - await navigator.clipboard.writeText(findingId) - setCopiedFindingId(findingId) - window.setTimeout(() => { - setCopiedFindingId((current) => - current === findingId ? null : current - ) - }, 1600) - } catch { - setCopiedFindingId(null) - } - } - - async function loadMore() { - if (loadingMore) return - setLoadingMore(true) - const nextPage = page + 1 - try { - const data = await getFindings(nextPage, perPage) - const rawFindings = data.findings || [] - const moreFindings = rawFindings.filter( - (finding) => typeof finding.id === 'string', - ) as Finding[] - if (rawFindings.length > 0) { - setFindings((prev) => [...prev, ...moreFindings]) - setPage(nextPage) - } - } finally { - setLoadingMore(false) - } -} - // ─── Keyboard navigation ──────────────────────────────────────────────────── - - function handleListKeyDown(e: React.KeyboardEvent) { - if (!sortedFindings.length) return - const currentIdx = selectedFinding - ? sortedFindings.findIndex((f) => f.id === selectedFinding.id) - : -1 - - if (e.key === 'ArrowDown') { - e.preventDefault() - const next = sortedFindings[Math.min(currentIdx + 1, sortedFindings.length - 1)] - if (next) setSelectedFindingId(next.id) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - const prev = sortedFindings[Math.max(currentIdx - 1, 0)] - if (prev) setSelectedFindingId(prev.id) - } -} - - // ─── Virtualizer ──────────────────────────────────────────────────────────── - const parentRef = useRef(null) - - const virtualizer = useVirtualizer({ - count: virtualRows.length, - getScrollElement: () => parentRef.current, - estimateSize: (index) => ROW_HEIGHTS[virtualRows[index]?.kind ?? 'finding'], - overscan: 6, - }) - - // Keep latest virtualRows/virtualizer available without making them - // reactive dependencies — they change on every filter/sort, but we only - // want to re-scroll when the *selection* actually changes. - const virtualRowsRef = useRef(virtualRows) - useEffect(() => { - virtualRowsRef.current = virtualRows - }) - - const virtualizerRef = useRef(virtualizer) - useEffect(() => { - virtualizerRef.current = virtualizer - }) - - // Scroll selected finding into view when the selection changes - useEffect(() => { - if (!selectedFindingId) return - const rows = virtualRowsRef.current - const rowIdx = rows.findIndex( - (row) => row.kind === 'finding' && row.finding.id === selectedFindingId, - ) - if (rowIdx !== -1) { - virtualizerRef.current.scrollToIndex(rowIdx, { align: 'auto', behavior: 'smooth' }) - } - }, [selectedFindingId]) - return ( -
-
- {/* Header */} -
-
- Triage Workspace v5.1 -
-
-
-

- Findings Desk -

-

- Active triage feed // {triageMetrics.total} total signals // {triageMetrics.unresolved} awaiting analyst action -

-
- -
- {[ - { label: 'Visible', value: triageMetrics.visible, tone: 'text-silver-bright' }, - { label: 'Critical + High', value: triageMetrics.active, tone: 'text-rag-red' }, - { label: 'Unresolved', value: triageMetrics.unresolved, tone: 'text-rag-amber' }, - { label: 'Reviewed', value: enrichedFindings.filter((finding) => finding.status === 'reviewed').length, tone: 'text-rag-green' }, - ].map((metric) => ( -
-

{metric.label}

-

{String(metric.value).padStart(2, '0')}

-
- ))} -
-
-
- - {/* Filter Bar */} -
-
-
-
- -
- setSearchQuery(event.target.value)} - placeholder="Title, target, CVE, remediation..." - className={`${filterControlClass} px-4 pr-12 placeholder:text-silver/20`} - /> - {searchQuery.trim() && ( - - )} -
-
- -
- - {severityOrder.map((severity) => ( - - ))} - - {/* Severity scale legend — help affordance (issue #835) */} -
- - - - {legendOpen && ( - -
-

- Severity_Scale -

- -
- -

- Ordered highest → lowest risk -

- -
    - {severityLegend.map(({ id, blurb }) => ( -
  • -
  • - ))} -
-
- )} -
-
-
-
- -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - setDateFrom(e.target.value)} - className={`${filterControlClass} [color-scheme:dark]`} - /> -
- -
- - setDateTo(e.target.value)} - className={`${filterControlClass} [color-scheme:dark]`} - /> -
-
- -
- - - -
- - - {showColumnChooser && ( -
- {Object.entries(columnVisibility).map(([key, value]) => ( - - ))} -
- )} -
- -
-
-
-
- - {/* ── Active filter summary strip ── */} - {activeFilters.length > 0 && ( -
- - Active Filters - - {activeFilters.map(({ key, label }) => ( - - {label} - - ))} -
- )} - - {/* Main Split Layout */} -
- {/* ── Virtualized Findings List ── */} - - {loading ? ( -
-

Synchronizing findings feed...

-
- ) : filteredFindings.length === 0 ? ( -
-

No Findings Match

-

Adjust filters to reopen the queue.

-
- ) : ( - <> - {/* Selection & Export Toolbar */} -
-
- - - {selectedIds.size > 0 && ( - - {selectedIds.size} Selected - - )} -
- - {selectedIds.size > 0 && ( -
- - {exportDropdownOpen && ( -
- - -
- )} -
- )} -
- -
- {/* Virtualizer inner container */} -
- {virtualizer.getVirtualItems().map((virtualItem) => { - const row = virtualRows[virtualItem.index] - - return ( -
- {row.kind === 'header' ? ( - /* ── Severity group header ── */ -
-
- -
-

- {severityConfig[row.severity].label} -

-

- {row.count} visible in queue -

-
-
-
- ) : ( - /* ── Finding row ── */ - (() => { - const { finding, isLastInGroup } = row - const isSelected = selectedFinding?.id === finding.id - const config = severityConfig[finding.severity] - - return ( -
- {/* Checkbox column */} -
- handleCheckboxChange(finding.id, e.target.checked)} - className="h-4 w-4 accent-[var(--accent-rag-red)] cursor-pointer" - /> -
- - {/* Details button */} - -
- ) - })() - )} -
- ) - })} -
-
- {!loading && findings.length < totalItems && ( -
- -
- )} - - )} -
- - {/* ── Detail Panel (unchanged) ── */} - -
- {selectedFinding ? ( -
-
-
- - {severityConfig[selectedFinding.severity].label} - - - {selectedFinding.status} - - {selectedFinding.cve ? ( - - {selectedFinding.cve} - - ) : null} -
- -
-

- Selected Finding -

- -

- {selectedFinding.title} -

- -
- - ID: {selectedFinding.id} - -
-
- -
-
-

Target

-

{selectedFinding.target || 'Unknown'}

-
-
-

Asset

-

- {selectedFinding.asset_id || selectedFinding.asset_refs?.[0] || 'N/A'} -

-
-
-

Category

-

{selectedFinding.category || 'Uncategorized'}

-
-
-

Finding Kind

-

- {selectedFinding.finding_kind?.replace('_', ' ') || 'N/A'} -

-
-
-

Observed

-

- {formatLocaleDate(selectedFinding.discovered_at)} -

-
-
-

CVSS

-

- {typeof selectedFinding.cvss === 'number' ? selectedFinding.cvss.toFixed(1) : 'N/A'} -

-
-
-

Validation

-

- {selectedFinding.validated ? 'Validated' : selectedFinding.validation_method || 'Unvalidated'} -

-
-
-

Analyst State

-

- {selectedFinding.analyst_status || 'N/A'} -

-
-
-

CPE

-

- {selectedFinding.cpe || 'N/A'} -

-
-
-

Seen Across Scans

-

- {selectedFinding.occurrence_count || 1} -

-
-
-

Evidence Count

-

- {selectedFinding.evidence_count || selectedFinding.evidence?.length || 0} -

-
-
- - {typeof selectedFinding.risk_score === 'number' && ( -
-
-

Risk Score

-

= 7 ? 'text-rag-red' : - selectedFinding.risk_score >= 4 ? 'text-rag-amber' : 'text-rag-blue' - }`}> - {selectedFinding.risk_score.toFixed(1)} -

-
- {selectedFinding.risk_factors && selectedFinding.risk_factors.length > 0 && ( -
- {selectedFinding.risk_factors.map((rf) => ( -
-
- {rf.label} - ({(rf.weight * 100).toFixed(0)}%) -
-
- {rf.score.toFixed(1)} - = 2 ? 'text-rag-red' : - rf.contribution >= 1 ? 'text-rag-amber' : 'text-silver/40' - }`}> - +{rf.contribution.toFixed(1)} - -
-
- ))} -
- )} -
- )} -
- -
-
-

Evidence Brief

-
-

{selectedFinding.description || 'No description provided.'}

- {selectedFinding.confidence_reason ? ( -

- {selectedFinding.confidence_reason} -

- ) : null} -
-
- - {selectedFinding.evidence && selectedFinding.evidence.length > 0 ? ( -
-

Evidence Items

-
- {selectedFinding.evidence.slice(0, 5).map((item, index) => ( -
-

- {String(item.label || item.type || 'evidence')} -

-

- {String(item.value ?? '')} -

-

- {String(item.source || 'scanner')} {item.confidence ? `// ${(Number(item.confidence) * 100).toFixed(0)}%` : ''} -

-
- ))} -
-
- ) : null} - - {selectedFinding.corroborating_sources && selectedFinding.corroborating_sources.length > 0 ? ( -
-

Corroborating Sources

-
- {selectedFinding.corroborating_sources.map((source) => ( - - {source} - - ))} -
-
- ) : null} - -
-

Remediation

-
-

- {selectedFinding.remediation || 'No remediation guidance captured.'} -

-
-
-
- -
-

Workflow Actions

-
- - - - - -
-
-
- ) : ( -
-

Queue Clear

-

- Select a finding to review evidence and remediation. -

-
- )} -
-
-
-
-
- ) -} + .catch(err => console.error(err)) \ No newline at end of file diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index c933b8ada..50d5210ae 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -223,7 +223,7 @@ export default function Settings() { try { await updateNotificationRule(rule.id, { is_active: !rule.is_active }) setNotificationRules((prev) => - prev.map((r) => (r.id === rule.id ? { ...r, is_active: !r.is_active } : r)), + (prev ?? []).map((r) => (r.id === rule.id ? { ...r, is_active: !r.is_active } : r)), ) } catch { addToast('Failed to update rule', 'error') @@ -401,7 +401,7 @@ export default function Settings() { onChange(type === 'number' ? parseInt(e.target.value) || 0 : e.target.value)} + onChange={(e) => onChange(type === 'number' ? parseInt(e.target.value, 10) || 0 : e.target.value)} placeholder={placeholder} className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-rag-blue font-bold focus:outline-none focus:border-rag-blue/50 transition-colors uppercase" />