From 4b02241f11ebc0979928f1b216674a9570eba545 Mon Sep 17 00:00:00 2001 From: Jean du Plessis Date: Wed, 17 Jun 2026 20:24:52 +0200 Subject: [PATCH 1/3] feat(security-agent): add audit report UI --- apps/web/package.json | 3 +- .../[id]/security-agent/audit-report/page.tsx | 16 + .../[id]/security-agent/layout.tsx | 12 +- .../[id]/security-agent/page.tsx | 9 +- .../security-agent/audit-report/page.tsx | 16 + .../src/app/(app)/security-agent/layout.tsx | 11 +- .../web/src/app/(app)/security-agent/page.tsx | 9 +- apps/web/src/app/globals.css | 330 +- apps/web/src/app/layout.tsx | 6 +- .../DismissFindingDialog.test.ts | 82 + .../security-agent/DismissFindingDialog.tsx | 52 +- .../security-agent/FindingDetailDialog.tsx | 3760 +++++++++++++---- .../security-agent/RepositoryFilter.tsx | 11 +- .../security-agent/SecurityAgentActionBar.tsx | 49 + .../SecurityAgentContext.test.ts | 76 + .../security-agent/SecurityAgentContext.tsx | 171 +- .../SecurityAgentGitHubInstallCta.tsx | 28 + .../security-agent/SecurityAgentLayout.tsx | 428 +- .../SecurityAuditReportPage.test.ts | 443 ++ .../SecurityAuditReportPage.tsx | 1549 +++++++ .../security-agent/SecurityConfigForm.tsx | 264 +- .../security-agent/SecurityConfigPage.tsx | 14 +- .../security-agent/SecurityConfigSections.tsx | 2 +- .../security-agent/SecurityDashboard.tsx | 1186 +++++- .../security-agent/SecurityFindingRow.test.ts | 102 + .../security-agent/SecurityFindingRow.tsx | 453 +- .../security-agent/SecurityFindingsCard.tsx | 456 +- .../security-agent/SecurityFindingsPage.tsx | 78 +- .../security-agent/dismiss-finding-form.ts | 73 + .../manual-analysis-admission-copy.test.ts | 75 +- .../manual-analysis-admission-copy.ts | 37 + .../remediation-unavailable-copy.test.ts | 27 + .../remediation-unavailable-copy.ts | 26 +- ...ecurity-agent-command-invalidation.test.ts | 2 +- .../security-agent-command-invalidation.ts | 1 + .../security-finding-list-presentation.ts | 186 + apps/web/src/components/ui/accordion.tsx | 4 +- apps/web/src/components/ui/badge.tsx | 2 +- apps/web/src/components/ui/button.tsx | 18 +- apps/web/src/components/ui/calendar.tsx | 176 + apps/web/src/components/ui/progress.tsx | 1 + pnpm-lock.yaml | 126 +- 42 files changed, 8633 insertions(+), 1737 deletions(-) create mode 100644 apps/web/src/app/(app)/organizations/[id]/security-agent/audit-report/page.tsx create mode 100644 apps/web/src/app/(app)/security-agent/audit-report/page.tsx create mode 100644 apps/web/src/components/security-agent/DismissFindingDialog.test.ts create mode 100644 apps/web/src/components/security-agent/SecurityAgentActionBar.tsx create mode 100644 apps/web/src/components/security-agent/SecurityAgentGitHubInstallCta.tsx create mode 100644 apps/web/src/components/security-agent/SecurityAuditReportPage.test.ts create mode 100644 apps/web/src/components/security-agent/SecurityAuditReportPage.tsx create mode 100644 apps/web/src/components/security-agent/SecurityFindingRow.test.ts create mode 100644 apps/web/src/components/security-agent/dismiss-finding-form.ts create mode 100644 apps/web/src/components/security-agent/remediation-unavailable-copy.test.ts create mode 100644 apps/web/src/components/security-agent/security-finding-list-presentation.ts create mode 100644 apps/web/src/components/ui/calendar.tsx diff --git a/apps/web/package.json b/apps/web/package.json index a89e29b9a..2d4801cb0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,9 +51,9 @@ "@kilocode/event-service": "workspace:*", "@kilocode/kilo-chat": "workspace:*", "@kilocode/kilo-chat-hooks": "workspace:*", - "@kilocode/mcp-gateway": "workspace:*", "@kilocode/kiloclaw-instance-tiers": "workspace:*", "@kilocode/kiloclaw-secret-catalog": "workspace:*", + "@kilocode/mcp-gateway": "workspace:*", "@kilocode/organization-entitlement": "workspace:*", "@kilocode/worker-utils": "workspace:*", "@linear/sdk": "76.0.0", @@ -151,6 +151,7 @@ "posthog-node": "5.10.4", "react": "19.2.6", "react-countup": "6.5.3", + "react-day-picker": "10.0.1", "react-dom": "19.2.6", "react-markdown": "10.1.0", "react-turnstile": "1.1.5", diff --git a/apps/web/src/app/(app)/organizations/[id]/security-agent/audit-report/page.tsx b/apps/web/src/app/(app)/organizations/[id]/security-agent/audit-report/page.tsx new file mode 100644 index 000000000..4384ebd19 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/security-agent/audit-report/page.tsx @@ -0,0 +1,16 @@ +import { Suspense } from 'react'; +import { SecurityAuditReportPage } from '@/components/security-agent/SecurityAuditReportPage'; + +export default function OrgAuditReportPage() { + return ( + + Loading audit report... + + } + > + + + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/security-agent/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/security-agent/layout.tsx index 638507712..f068dd0d0 100644 --- a/apps/web/src/app/(app)/organizations/[id]/security-agent/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/security-agent/layout.tsx @@ -1,11 +1,10 @@ -import { PageContainer } from '@/components/layouts/PageContainer'; import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { SecurityAgentLayout } from '@/components/security-agent/SecurityAgentLayout'; import { SecurityAgentProvider } from '@/components/security-agent/SecurityAgentContext'; export const metadata = { title: 'Security Agent | Kilo Code', - description: 'Monitor and manage Dependabot security alerts', + description: 'Monitor and manage Security Findings synced from Dependabot', }; type LayoutProps = { @@ -17,12 +16,11 @@ export default async function OrgSecurityAgentLayout({ params, children }: Layou return ( ( - - - {children} - - + + {children} + )} /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/security-agent/page.tsx b/apps/web/src/app/(app)/organizations/[id]/security-agent/page.tsx index 537010dd3..9974b42d9 100644 --- a/apps/web/src/app/(app)/organizations/[id]/security-agent/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/security-agent/page.tsx @@ -7,10 +7,13 @@ import { useSecurityAgent } from '@/components/security-agent/SecurityAgentConte import { SecurityDashboard } from '@/components/security-agent/SecurityDashboard'; export default function OrgSecurityAgentDashboardPage() { - const { hasIntegration, isEnabled, isLoadingConfig, organizationId } = useSecurityAgent(); + const { hasIntegration, isEnabled, isLoadingConfig, isLoadingPermission, organizationId } = + useSecurityAgent(); const router = useRouter(); - const shouldRedirectToConfig = hasIntegration && isEnabled === false && !!organizationId; + const shouldRedirectToConfig = + !!organizationId && + ((!isLoadingPermission && !hasIntegration) || (hasIntegration && isEnabled === false)); useEffect(() => { if (shouldRedirectToConfig) { @@ -26,7 +29,7 @@ export default function OrgSecurityAgentDashboardPage() { ); } - if (hasIntegration && isLoadingConfig) { + if (isLoadingPermission || (hasIntegration && isLoadingConfig)) { return (
+ } + > + + + ); +} diff --git a/apps/web/src/app/(app)/security-agent/layout.tsx b/apps/web/src/app/(app)/security-agent/layout.tsx index 0ecdea21c..1e8336b45 100644 --- a/apps/web/src/app/(app)/security-agent/layout.tsx +++ b/apps/web/src/app/(app)/security-agent/layout.tsx @@ -1,18 +1,15 @@ -import { PageContainer } from '@/components/layouts/PageContainer'; import { SecurityAgentLayout } from '@/components/security-agent/SecurityAgentLayout'; import { SecurityAgentProvider } from '@/components/security-agent/SecurityAgentContext'; export const metadata = { title: 'Security Agent | Kilo Code', - description: 'Monitor and manage Dependabot security alerts', + description: 'Monitor and manage Security Findings synced from Dependabot', }; export default function SecurityAgentRootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} - - + + {children} + ); } diff --git a/apps/web/src/app/(app)/security-agent/page.tsx b/apps/web/src/app/(app)/security-agent/page.tsx index 3c5033dcf..6fddb31b3 100644 --- a/apps/web/src/app/(app)/security-agent/page.tsx +++ b/apps/web/src/app/(app)/security-agent/page.tsx @@ -7,15 +7,16 @@ import { useSecurityAgent } from '@/components/security-agent/SecurityAgentConte import { SecurityDashboard } from '@/components/security-agent/SecurityDashboard'; export default function SecurityAgentDashboardPage() { - const { hasIntegration, isEnabled, isLoadingConfig } = useSecurityAgent(); + const { hasIntegration, isEnabled, isLoadingConfig, isLoadingPermission } = useSecurityAgent(); const router = useRouter(); // Redirect per truth table: - // No integration -> show dashboard with install CTA (handled by SecurityDashboard) + // No integration -> redirect to settings with install CTA // Installed + disabled -> redirect to config // Installed + enabled -> show dashboard // isEnabled is undefined while config is loading — wait before deciding - const shouldRedirectToConfig = hasIntegration && isEnabled === false; + const shouldRedirectToConfig = + (!isLoadingPermission && !hasIntegration) || (hasIntegration && isEnabled === false); useEffect(() => { if (shouldRedirectToConfig) { @@ -31,7 +32,7 @@ export default function SecurityAgentDashboardPage() { ); } - if (hasIntegration && isLoadingConfig) { + if (isLoadingPermission || (hasIntegration && isLoadingConfig)) { return (
diff --git a/apps/web/src/components/security-agent/FindingDetailDialog.tsx b/apps/web/src/components/security-agent/FindingDetailDialog.tsx index 66ef9a0d9..7ea8adb7a 100644 --- a/apps/web/src/components/security-agent/FindingDetailDialog.tsx +++ b/apps/web/src/components/security-agent/FindingDetailDialog.tsx @@ -1,781 +1,2946 @@ 'use client'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Dialog, + DialogClose, DialogContent, DialogDescription, - DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { SeverityBadge } from './SeverityBadge'; -import { FindingStatusBadge } from './FindingStatusBadge'; -import { ExploitabilityBadge } from './ExploitabilityBadge'; -import { MarkdownProse } from './MarkdownProse'; -import { format, formatDistanceToNow, isPast } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; +import type { SecurityFinding } from '@kilocode/db/schema'; +import { formatDistanceToNow, isPast } from 'date-fns'; import { + Ban, + Brain, + Check, + CheckCircle2, + CircleHelp, + Clock3, ExternalLink, + FileCheck2, + FileCode2, + FileWarning, + GitBranch, + GitMerge, + GitPullRequest, + Info, + Loader2, Package, - Clock, - CheckCircle2, + RefreshCw, + Search, + ShieldAlert, + ShieldCheck, + Sparkles, + Square, + TriangleAlert, + UserRound, + Wrench, + X, XCircle, - Brain, - Loader2, - Zap, - GitPullRequest, - RotateCw, - AlertCircle, + type LucideIcon, } from 'lucide-react'; -import type { SecurityFinding } from '@kilocode/db/schema'; -import { useTRPC } from '@/lib/trpc/utils'; -import { useQuery } from '@tanstack/react-query'; import Link from 'next/link'; +import { MarkdownProse } from './MarkdownProse'; import { useSecurityAgent } from './SecurityAgentContext'; -import { securityAgentCommandAdmissionCopy } from './security-agent-command-copy'; -import { manualAnalysisAdmissionCopy } from './manual-analysis-admission-copy'; -import type { SecurityFindingWithRemediation } from './SecurityFindingRow'; -import { getRemediationUnavailableCopy } from './remediation-unavailable-copy'; +import { + isAwaitingManualAnalysisAdmission, + manualAnalysisAdmissionCopy, + manualAnalysisCapacityFullCopy, +} from './manual-analysis-admission-copy'; +import { + getRemediationUnavailableCopy, + isCodebaseAnalysisRequiredReason, +} from './remediation-unavailable-copy'; +import type { SecurityFindingWithRemediation } from '@/lib/security-agent/db/security-remediation'; -type Severity = 'critical' | 'high' | 'medium' | 'low'; type FindingAnalysis = SecurityFinding['analysis']; -type StartAnalysis = (options?: { forceSandbox?: boolean; retrySandboxOnly?: boolean }) => void; - -const ANALYSIS_POLL_INTERVAL_MS = 3000; -const statusPanelClassName = 'rounded-lg border border-border bg-muted/40 p-3'; -const linkClassName = - 'text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex rounded-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none'; +type FindingTab = 'details' | 'analysis' | 'remediation'; +type Tone = 'success' | 'warning' | 'destructive' | 'neutral'; +type StartAnalysisOptions = { + forceSandbox?: boolean; + retrySandboxOnly?: boolean; + restartActive?: boolean; +}; +type StartAnalysis = (options?: StartAnalysisOptions) => void; +type StartFindingAnalysis = (findingId: string, options?: StartAnalysisOptions) => void; type RemediationAttempt = { id: string; status: string; origin: string; attemptNumber: number; + requestedByUserId: string | null; remediationModelSlug: string; branchName: string; prUrl: string | null; + prNumber: number | null; prDraft: boolean | null; failureCode: string | null; blockedReason: string | null; lastErrorRedacted: string | null; + validationEvidence: Record[] | null; riskNotes: string | null; draftReason: string | null; + cancellationRequestedAt: string | null; queuedAt: string; launchedAt: string | null; completedAt: string | null; updatedAt: string; }; -function isSeverity(value: string): value is Severity { - return ['critical', 'high', 'medium', 'low'].includes(value); -} +type StatusValue = { + value: string; + tone: Tone; +}; + +type DetailFact = { + label: string; + value: string; + mono?: boolean; +}; + +type SummaryItem = { + label: string; + value: string; + detail: string; + icon: LucideIcon; + tone: Tone; +}; + +type ProgressStep = { + title: string; + detail: string; + state: 'done' | 'running' | 'waiting' | 'attention' | 'pending' | 'error'; +}; + +type AnalysisAction = + | 'none' + | 'start-analysis' + | 'retry-analysis' + | 'start-remediation' + | 'view-remediation' + | 'dismiss' + | 'source' + | 'cloud-agent'; + +type AnalysisPresentation = { + hero: { + title: string; + description: string; + icon: LucideIcon; + tone: Tone; + spinning?: boolean; + }; + summary: SummaryItem[]; + context: string; + action: { + label: 'Next step' | 'Current status'; + title: string; + description: string; + buttonLabel?: string; + buttonIcon?: LucideIcon; + kind: AnalysisAction; + primary?: boolean; + }; + disclosureTitle: string; +}; + +type RemediationPresentation = { + hero: { + title: string; + description: string; + icon: LucideIcon; + tone: Tone; + spinning?: boolean; + }; + summary: SummaryItem[]; + context: string; + action: { + label: 'Next step' | 'Attempt controls' | 'Current status'; + title: string; + description: string; + }; + disclosureTitle: string; + steps: ProgressStep[]; +}; + +const ANALYSIS_POLL_INTERVAL_MS = 3000; +const SUPERSEDED_PREFIX = 'superseded:'; +const utcDateFormatter = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}); +const utcTimeFormatter = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', +}); + +const toneStyles: Record< + Tone, + { status: string; icon: string; text: string; step: string; border: string } +> = { + success: { + status: 'border-status-success-border bg-status-success-surface text-status-success', + icon: 'bg-status-success-surface text-status-success-icon ring-status-success-border', + text: 'text-status-success', + step: 'bg-status-success-surface text-status-success ring-status-success-border', + border: 'border-status-success-border bg-status-success-surface', + }, + warning: { + status: 'border-status-warning-border bg-status-warning-surface text-status-warning', + icon: 'bg-status-warning-surface text-status-warning-icon ring-status-warning-border', + text: 'text-status-warning', + step: 'bg-status-warning-surface text-status-warning ring-status-warning-border', + border: 'border-status-warning-border bg-status-warning-surface', + }, + destructive: { + status: + 'border-status-destructive-border bg-status-destructive-surface text-status-destructive', + icon: 'bg-status-destructive-surface text-status-destructive-icon ring-status-destructive-border', + text: 'text-status-destructive', + step: 'bg-status-destructive-surface text-status-destructive ring-status-destructive-border', + border: 'border-status-destructive-border bg-status-destructive-surface', + }, + neutral: { + status: 'border-status-neutral-border bg-status-neutral-surface text-status-neutral', + icon: 'bg-status-neutral-surface text-status-neutral-icon ring-status-neutral-border', + text: 'text-status-neutral', + step: 'bg-status-neutral-surface text-status-neutral-icon ring-status-neutral-border', + border: 'border-status-neutral-border bg-surface-inset', + }, +}; + +const dismissalReasonLabels: Record = { + fix_started: 'a fix has already started', + no_bandwidth: 'no bandwidth is available', + tolerable_risk: 'the risk is tolerable', + inaccurate: 'the finding is inaccurate', + not_used: 'vulnerable code is not used', +}; function LoadingSpinner({ className = 'size-4' }: { className?: string }) { return (