Files
moreminimore-vibe/src/components/preview_panel/SecurityPanel.tsx
Will Chen 369149b202 Security review: fix multiple issues (#1699)
See #1692

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds multi-select with a "Fix X Issues" bulk action to Security Review
(severity-sorted, with animated header button), clears selections on
refresh, and improves streaming error logs; includes e2e coverage.
> 
> - **Security Review UI
(`src/components/preview_panel/SecurityPanel.tsx`)**:
>   - **Multi-select & Bulk Fix**:
> - Add per-row checkboxes and a "Select all" checkbox in
`FindingsTable`; sort by severity; ARIA labels.
> - Track `selectedFindings`; clear on new data; header shows animated
"Fix X Issues" button (`Wrench` icon) that creates one chat with a
combined prompt for selected issues.
> - **Fix Single Issue**: Preserve existing per-row "Fix Issue" flow
with loading states.
> - **Tests**:
> - Add e2e test `security review - multi-select and fix issues` and
snapshots for selection table and combined prompt.
> - **IPC (`src/ipc/ipc_client.ts`)**:
> - Enhance error logging (`console.error`) in `streamMessage` paths;
simplify `cancelChatStream` (remove stale cleanup).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
08b9f92814e2a676d0a8de1badf7dc79cd82a14a. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Add multi-select to the Security Review so you can select issues and fix
them in one go. Improves error handling in chat streaming and adds an
e2e test for the new flow.

- New Features
- Checkboxes per finding and a “Select all” checkbox, with
severity-sorted rows.
- Header shows an animated “Fix X Issues” button when items are
selected; creates one chat with a combined prompt; clears selection
after.
  - New e2e test: multi-select and bulk fix.

- Bug Fixes
  - Clear selections when new review results load.
- Better error logging in IpcClient for streaming failures; simplify
cancelChatStream to avoid false errors.

<sup>Written for commit 08b9f92814e2a676d0a8de1badf7dc79cd82a14a.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-11-03 19:58:26 -08:00

1004 lines
32 KiB
TypeScript

import { useAtomValue, useSetAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useSecurityReview } from "@/hooks/useSecurityReview";
import { IpcClient } from "@/ipc/ipc_client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import {
Shield,
AlertTriangle,
AlertCircle,
Info,
ChevronDown,
Pencil,
Wrench,
} from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat";
import { showError } from "@/lib/toast";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import type { SecurityFinding, SecurityReviewResult } from "@/ipc/ipc_types";
import { useState, useEffect } from "react";
import { VanillaMarkdownParser } from "@/components/chat/DyadMarkdownParser";
import { showSuccess, showWarning } from "@/lib/toast";
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
import { useQueryClient } from "@tanstack/react-query";
const getSeverityColor = (level: SecurityFinding["level"]) => {
switch (level) {
case "critical":
return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-200 dark:border-red-800";
case "high":
return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border-orange-200 dark:border-orange-800";
case "medium":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800";
case "low":
return "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800";
}
};
const getSeverityIcon = (level: SecurityFinding["level"]) => {
switch (level) {
case "critical":
return <AlertTriangle className="h-4 w-4" />;
case "high":
return <AlertCircle className="h-4 w-4" />;
case "medium":
return <AlertCircle className="h-4 w-4" />;
case "low":
return <Info className="h-4 w-4" />;
}
};
const DESCRIPTION_PREVIEW_LENGTH = 150;
const createFindingKey = (finding: {
title: string;
level: string;
description: string;
}): string => {
return JSON.stringify({
title: finding.title,
level: finding.level,
description: finding.description,
});
};
const formatTimeAgo = (input: string | number | Date): string => {
const timestampMs = new Date(input).getTime();
const nowMs = Date.now();
const diffMs = Math.max(0, nowMs - timestampMs);
const minutes = Math.floor(diffMs / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) {
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
const days = Math.floor(hours / 24);
return `${days} day${days === 1 ? "" : "s"} ago`;
};
const getSeverityOrder = (level: SecurityFinding["level"]): number => {
switch (level) {
case "critical":
return 0;
case "high":
return 1;
case "medium":
return 2;
case "low":
return 3;
default:
return 4;
}
};
function SeverityBadge({ level }: { level: SecurityFinding["level"] }) {
return (
<Badge
variant="outline"
className={`${getSeverityColor(level)} uppercase text-xs font-semibold flex items-center gap-1 w-fit`}
>
<span className="flex-shrink-0">{getSeverityIcon(level)}</span>
<span>{level}</span>
</Badge>
);
}
function RunReviewButton({
isRunning,
onRun,
}: {
isRunning: boolean;
onRun: () => void;
}) {
return (
<Button onClick={onRun} className="gap-2" disabled={isRunning}>
{isRunning ? (
<>
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Running Security Review...
</>
) : (
<>
<Shield className="w-4 h-4" />
Run Security Review
</>
)}
</Button>
);
}
function ReviewSummary({ data }: { data: SecurityReviewResult }) {
const counts = data.findings.reduce(
(acc, finding) => {
acc[finding.level] = (acc[finding.level] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
const severityLevels: Array<SecurityFinding["level"]> = [
"critical",
"high",
"medium",
"low",
];
return (
<div className="space-y-1 mt-1">
<div className="text-sm text-gray-600 dark:text-gray-400">
Last reviewed {formatTimeAgo(data.timestamp)}
</div>
<div className="flex items-center gap-3 text-sm">
{severityLevels
.filter((level) => counts[level] > 0)
.map((level) => (
<span key={level} className="flex items-center gap-1.5">
<span className="flex-shrink-0">{getSeverityIcon(level)}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{counts[level]}
</span>
<span className="text-gray-600 dark:text-gray-400 capitalize">
{level}
</span>
</span>
))}
</div>
</div>
);
}
function SecurityHeader({
isRunning,
onRun,
data,
onOpenEditRules,
selectedCount,
onFixSelected,
isFixingSelected,
}: {
isRunning: boolean;
onRun: () => void;
data?: SecurityReviewResult | undefined;
onOpenEditRules: () => void;
selectedCount: number;
onFixSelected: () => void;
isFixingSelected: boolean;
}) {
const [isButtonVisible, setIsButtonVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (selectedCount > 0) {
// Show immediately
setShouldRender(true);
// Trigger animation after render
setTimeout(() => setIsButtonVisible(true), 10);
} else {
// Trigger exit animation
setIsButtonVisible(false);
// Hide after animation completes
const timer = setTimeout(() => setShouldRender(false), 300);
return () => clearTimeout(timer);
}
}, [selectedCount]);
return (
<div className="sticky top-0 z-10 bg-background pt-3 pb-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1 flex items-center gap-2">
<Shield className="w-5 h-5" />
Security Review
<Badge variant="secondary" className="uppercase tracking-wide">
experimental
</Badge>
</h1>
<div className="text-sm">
<p>
<a
className="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
onClick={() =>
IpcClient.getInstance().openExternalUrl(
"https://www.dyad.sh/docs/guides/security-review",
)
}
>
Open Security Review docs
</a>
</p>
</div>
{data && data.findings.length > 0 && <ReviewSummary data={data} />}
</div>
<div className="flex flex-col items-end gap-2">
<Button variant="outline" onClick={onOpenEditRules}>
<Pencil className="w-4 h-4" />
Edit Security Rules
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={onFixSelected}
className="gap-2 transition-all duration-300"
disabled={isFixingSelected}
style={{
visibility: shouldRender ? "visible" : "hidden",
opacity: isButtonVisible ? 1 : 0,
transform: isButtonVisible
? "translateY(0)"
: "translateY(-8px)",
pointerEvents: shouldRender ? "auto" : "none",
}}
>
{isFixingSelected ? (
<>
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Fixing {selectedCount} Issue{selectedCount !== 1 ? "s" : ""}
...
</>
) : (
<>
<Wrench className="w-4 h-4" />
Fix {selectedCount} Issue{selectedCount !== 1 ? "s" : ""}
</>
)}
</Button>
<RunReviewButton isRunning={isRunning} onRun={onRun} />
</div>
</div>
</div>
</div>
);
}
function LoadingView() {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mt-4">
Loading...
</h2>
</div>
);
}
function NoAppSelectedView() {
return (
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<Shield className="w-8 h-8 text-gray-400" />
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
No App Selected
</h2>
<p className="text-gray-600 dark:text-gray-400 max-w-md">
Select an app to run a security review
</p>
</div>
);
}
function RunningReviewCard() {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<div className="w-16 h-16 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-blue-600 dark:text-blue-400 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Security review is running
</h3>
<p className="text-gray-600 dark:text-gray-400">
Results will be available soon.
</p>
</div>
</CardContent>
</Card>
);
}
function NoReviewCard({
isRunning,
onRun,
}: {
isRunning: boolean;
onRun: () => void;
}) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
No Security Review Found
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Run a security review to identify potential vulnerabilities in your
application.
</p>
<RunReviewButton isRunning={isRunning} onRun={onRun} />
</div>
</CardContent>
</Card>
);
}
function NoIssuesCard({ data }: { data?: SecurityReviewResult }) {
return (
<Card>
<CardContent className="pt-6">
<div className="text-center py-8">
<div className="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
No Security Issues Found
</h3>
<p className="text-gray-600 dark:text-gray-400">
Your application passed the security review with no issues detected.
</p>
{data && (
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2">
Last reviewed {formatTimeAgo(data.timestamp)}
</p>
)}
</div>
</CardContent>
</Card>
);
}
function FindingsTable({
findings,
onOpenDetails,
onFix,
fixingFindingKey,
selectedFindings,
onToggleSelection,
onToggleSelectAll,
}: {
findings: SecurityFinding[];
onOpenDetails: (finding: SecurityFinding) => void;
onFix: (finding: SecurityFinding) => void;
fixingFindingKey?: string | null;
selectedFindings: Set<string>;
onToggleSelection: (findingKey: string) => void;
onToggleSelectAll: () => void;
}) {
const sortedFindings = [...findings].sort(
(a, b) => getSeverityOrder(a.level) - getSeverityOrder(b.level),
);
const allSelected =
sortedFindings.length > 0 &&
sortedFindings.every((finding) =>
selectedFindings.has(createFindingKey(finding)),
);
return (
<div
className="border rounded-lg overflow-hidden"
data-testid="security-findings-table"
>
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider w-12">
<Checkbox
checked={allSelected}
onCheckedChange={onToggleSelectAll}
aria-label="Select all issues"
/>
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider w-24">
Level
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Issue
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider w-32">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{sortedFindings.map((finding, index) => {
const isLongDescription =
finding.description.length > DESCRIPTION_PREVIEW_LENGTH;
const displayDescription = isLongDescription
? finding.description.substring(0, DESCRIPTION_PREVIEW_LENGTH) +
"..."
: finding.description;
const findingKey = createFindingKey(finding);
const isFixing = fixingFindingKey === findingKey;
const isSelected = selectedFindings.has(findingKey);
return (
<tr
key={index}
className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-4 align-top">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelection(findingKey)}
aria-label={`Select ${finding.title}`}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-4 py-4 align-top">
<SeverityBadge level={finding.level} />
</td>
<td className="px-4 py-4">
<div
className="space-y-2 cursor-pointer"
role="button"
tabIndex={0}
onClick={() => onOpenDetails(finding)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenDetails(finding);
}
}}
>
<div className="font-medium text-gray-900 dark:text-gray-100">
{finding.title}
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none">
<VanillaMarkdownParser content={displayDescription} />
</div>
{isLongDescription && (
<Button
onClick={(e) => {
e.stopPropagation();
onOpenDetails(finding);
}}
size="sm"
variant="ghost"
className="h-7 px-2 py-0 gap-1"
>
<ChevronDown className="w-3 h-3" />
Show more
</Button>
)}
</div>
</td>
<td className="px-4 py-4 align-top text-right">
<Button
onClick={() => onFix(finding)}
size="sm"
variant="default"
className="gap-2"
disabled={isFixing}
>
{isFixing ? (
<>
<svg
className="w-4 h-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Fixing Issue...
</>
) : (
<>Fix Issue</>
)}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function FindingDetailsDialog({
open,
finding,
onClose,
onFix,
fixingFindingKey,
}: {
open: boolean;
finding: SecurityFinding | null;
onClose: (open: boolean) => void;
onFix: (finding: SecurityFinding) => void;
fixingFindingKey?: string | null;
}) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[80vw] md:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between gap-3 pr-4">
<span className="truncate">{finding?.title}</span>
{finding && <SeverityBadge level={finding.level} />}
</DialogTitle>
</DialogHeader>
<div className="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none break-words max-h-[60vh] overflow-auto">
{finding && <VanillaMarkdownParser content={finding.description} />}
</div>
<DialogFooter>
<Button
onClick={() => {
if (finding) {
onFix(finding);
onClose(false);
}
}}
disabled={
finding ? fixingFindingKey === createFindingKey(finding) : false
}
>
{finding && fixingFindingKey === createFindingKey(finding) ? (
<>
<svg
className="w-4 h-4 animate-spin mr-2"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 0 1 4 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Fixing Issue...
</>
) : (
<>Fix Issue</>
)}
</Button>
<DialogClose asChild>
<Button variant="outline">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export const SecurityPanel = () => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { streamMessage } = useStreamChat({ hasChatId: false });
const { data, isLoading, error, refetch } = useSecurityReview(selectedAppId);
const [isRunningReview, setIsRunningReview] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [detailsFinding, setDetailsFinding] = useState<SecurityFinding | null>(
null,
);
const [isEditRulesOpen, setIsEditRulesOpen] = useState(false);
const [rulesContent, setRulesContent] = useState("");
const [fixingFindingKey, setFixingFindingKey] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [selectedFindings, setSelectedFindings] = useState<Set<string>>(
new Set(),
);
const [isFixingSelected, setIsFixingSelected] = useState(false);
const {
content: fetchedRules,
loading: isFetchingRules,
refreshFile: refetchRules,
} = useLoadAppFile(
isEditRulesOpen && selectedAppId ? selectedAppId : null,
isEditRulesOpen ? "SECURITY_RULES.md" : null,
);
useEffect(() => {
if (fetchedRules !== null) {
setRulesContent(fetchedRules);
}
}, [fetchedRules]);
// Clear selections when data changes (e.g., after a new review)
useEffect(() => {
setSelectedFindings(new Set());
}, [data]);
const handleSaveRules = async () => {
if (!selectedAppId) {
showError("No app selected");
return;
}
try {
setIsSaving(true);
const ipcClient = IpcClient.getInstance();
const { warning } = await ipcClient.editAppFile(
selectedAppId,
"SECURITY_RULES.md",
rulesContent,
);
await queryClient.invalidateQueries({
queryKey: ["versions", selectedAppId],
});
if (warning) {
showWarning(warning);
} else {
showSuccess("Security rules saved");
}
setIsEditRulesOpen(false);
refetchRules();
} catch (err: any) {
showError(`Failed to save security rules: ${err.message || err}`);
} finally {
setIsSaving(false);
}
};
const openFindingDetails = (finding: SecurityFinding) => {
setDetailsFinding(finding);
setDetailsOpen(true);
};
const handleRunSecurityReview = async () => {
if (!selectedAppId) {
showError("No app selected");
return;
}
try {
setIsRunningReview(true);
// Create a new chat
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
// Navigate to the new chat
setSelectedChatId(chatId);
await navigate({ to: "/chat", search: { id: chatId } });
// Stream the security review prompt
await streamMessage({
prompt: "/security-review",
chatId,
onSettled: () => {
refetch();
setIsRunningReview(false);
},
});
} catch (err) {
showError(`Failed to run security review: ${err}`);
setIsRunningReview(false);
}
};
const handleFixIssue = async (finding: SecurityFinding) => {
if (!selectedAppId) {
showError("No app selected");
return;
}
try {
const key = createFindingKey(finding);
setFixingFindingKey(key);
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
// Navigate to the new chat
setSelectedChatId(chatId);
await navigate({ to: "/chat", search: { id: chatId } });
const prompt = `Please fix the following security issue in a simple and effective way:
**${finding.title}** (${finding.level} severity)
${finding.description}`;
await streamMessage({
prompt,
chatId,
onSettled: () => {
setFixingFindingKey(null);
},
});
} catch (err) {
showError(`Failed to create fix chat: ${err}`);
setFixingFindingKey(null);
}
};
const handleToggleSelection = (findingKey: string) => {
setSelectedFindings((prev) => {
const newSet = new Set(prev);
if (newSet.has(findingKey)) {
newSet.delete(findingKey);
} else {
newSet.add(findingKey);
}
return newSet;
});
};
const handleToggleSelectAll = () => {
if (!data?.findings) return;
const sortedFindings = [...data.findings].sort(
(a, b) => getSeverityOrder(a.level) - getSeverityOrder(b.level),
);
const allKeys = sortedFindings.map((finding) => createFindingKey(finding));
const allSelected = allKeys.every((key) => selectedFindings.has(key));
if (allSelected) {
setSelectedFindings(new Set());
} else {
setSelectedFindings(new Set(allKeys));
}
};
const handleFixSelected = async () => {
if (!selectedAppId || selectedFindings.size === 0 || !data?.findings) {
showError("No issues selected");
return;
}
try {
setIsFixingSelected(true);
// Get the selected findings
const findingsToFix = data.findings.filter((finding) =>
selectedFindings.has(createFindingKey(finding)),
);
// Create a new chat
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
// Navigate to the new chat
setSelectedChatId(chatId);
await navigate({ to: "/chat", search: { id: chatId } });
// Build a comprehensive prompt for all selected issues
const issuesList = findingsToFix
.map(
(finding, index) =>
`${index + 1}. **${finding.title}** (${finding.level} severity)\n${finding.description}`,
)
.join("\n\n");
const prompt = `Please fix the following ${findingsToFix.length} security issue${findingsToFix.length !== 1 ? "s" : ""} in a simple and effective way:
${issuesList}`;
await streamMessage({
prompt,
chatId,
onSettled: () => {
setIsFixingSelected(false);
setSelectedFindings(new Set());
},
});
} catch (err) {
showError(`Failed to create fix chat: ${err}`);
setIsFixingSelected(false);
}
};
if (isLoading) {
return <LoadingView />;
}
if (!selectedAppId) {
return <NoAppSelectedView />;
}
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-4 pt-0 space-y-4">
<SecurityHeader
isRunning={isRunningReview}
onRun={handleRunSecurityReview}
data={data}
onOpenEditRules={() => {
setIsEditRulesOpen(true);
if (selectedAppId) {
refetchRules();
}
}}
selectedCount={selectedFindings.size}
onFixSelected={handleFixSelected}
isFixingSelected={isFixingSelected}
/>
{isRunningReview ? (
<RunningReviewCard />
) : error ? (
<NoReviewCard
isRunning={isRunningReview}
onRun={handleRunSecurityReview}
/>
) : data && data.findings.length > 0 ? (
<FindingsTable
findings={data.findings}
onOpenDetails={openFindingDetails}
onFix={handleFixIssue}
fixingFindingKey={fixingFindingKey}
selectedFindings={selectedFindings}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
/>
) : (
<NoIssuesCard data={data} />
)}
<FindingDetailsDialog
open={detailsOpen}
finding={detailsFinding}
onClose={setDetailsOpen}
onFix={handleFixIssue}
fixingFindingKey={fixingFindingKey}
/>
<Dialog open={isEditRulesOpen} onOpenChange={setIsEditRulesOpen}>
<DialogContent className="sm:max-w-2xl md:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>Edit Security Rules</DialogTitle>
</DialogHeader>
<div className="text-sm text-gray-600 dark:text-gray-400">
This allows you to add additional context about your project
specifically for security reviews. This content is saved to the{" "}
<code className="text-xs">SECURITY_RULES.md</code> file. This can
help catch additional issues or avoid flagging issues that are not
relevant for your app.
</div>
<div className="mt-3">
<textarea
className="w-full h-72 rounded-md border border-gray-300 dark:border-gray-700 bg-transparent p-3 font-mono text-sm outline-none focus:ring-2 focus:ring-blue-500"
value={rulesContent}
onChange={(e) => setRulesContent(e.target.value)}
placeholder="# SECURITY_RULES.md\n\nDescribe relevant security context, accepted risks, non-issues, and environment details."
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button
onClick={handleSaveRules}
disabled={isSaving || isFetchingRules}
>
{isSaving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
};