fase(15): reporting module with pdf generation

This commit is contained in:
debian
2026-03-06 05:57:05 -05:00
parent 3ff36f0b6a
commit cffa1aeea9
64 changed files with 3462 additions and 87 deletions

View File

@@ -11,21 +11,21 @@ import { Setup } from '@/pages/Setup'
import { Dashboard } from '@/pages/Dashboard'
import { SessionList } from '@/pages/sessions/SessionList'
import { SessionDetail } from '@/pages/sessions/SessionDetail'
function FindingsList() {
return <div className="text-muted-foreground p-4">Findings Coming in Phase 13</div>
}
function FindingDetail() {
return <div className="text-muted-foreground p-4">Finding Detail Coming in Phase 13</div>
}
function Reports() {
return <div className="text-muted-foreground p-4">Reports Coming in Phase 15</div>
}
import { FindingsList } from '@/pages/findings/FindingsList'
import { FindingDetail } from '@/pages/findings/FindingDetail'
import { SettingsLayout } from '@/pages/settings/SettingsLayout'
import { ProfileSection } from '@/pages/settings/ProfileSection'
import { OrganizationSection } from '@/pages/settings/OrganizationSection'
import { ApiKeysSection } from '@/pages/settings/ApiKeysSection'
import { ExplorationDefaultsSection } from '@/pages/settings/ExplorationDefaultsSection'
import { NotificationsSection } from '@/pages/settings/NotificationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { Reports } from '@/pages/Reports'
function VisualReview() {
return <div className="text-muted-foreground p-4">Visual Review Coming in Phase 20</div>
}
function Settings() {
return <div className="text-muted-foreground p-4">Settings Coming in Phase 14</div>
}
export default function App() {
return (
@@ -50,7 +50,16 @@ export default function App() {
<Route path="/findings/:id" element={<FindingDetail />} />
<Route path="/reports" element={<Reports />} />
<Route path="/visual-review" element={<VisualReview />} />
<Route path="/settings/*" element={<Settings />} />
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Navigate to="profile" replace />} />
<Route path="profile" element={<ProfileSection />} />
<Route path="organization" element={<OrganizationSection />} />
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="notifications" element={<NotificationsSection />} />
<Route path="appearance" element={<AppearanceSection />} />
<Route path="license" element={<LicenseSection />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,122 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Sparkles } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useQueryClient } from '@tanstack/react-query'
import type { AIEnrichment } from '../../types'
interface AIAnalysisPanelProps {
findingId: string
enrichment?: AIEnrichment | null
}
const CONFIDENCE_COLOR: Record<string, string> = {
high: 'bg-green-500/15 text-green-600 border-green-500/30',
medium: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
low: 'bg-red-500/15 text-red-500 border-red-500/30',
}
export function AIAnalysisPanel({ findingId, enrichment }: AIAnalysisPanelProps) {
const queryClient = useQueryClient()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleAnalyze() {
setLoading(true)
setError(null)
try {
await apiFetch(`/api/findings/${findingId}/enrich`, { method: 'POST' })
await queryClient.invalidateQueries({ queryKey: ['findings', findingId] })
} catch (e) {
setError(e instanceof Error ? e.message : 'Analysis failed')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-5/6" />
</CardContent>
</Card>
)
}
if (!enrichment) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
</CardHeader>
<CardContent>
{error && <p className="text-sm text-destructive mb-3">{error}</p>}
<p className="text-sm text-muted-foreground mb-3">
Get AI-powered root cause analysis, user impact assessment, and suggested fixes.
</p>
<Button size="sm" onClick={() => void handleAnalyze()} className="gap-2">
<Sparkles className="h-3.5 w-3.5" />
Analyze with AI
</Button>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI Analysis
</CardTitle>
<Badge variant="outline" className={`text-xs ${CONFIDENCE_COLOR[enrichment.confidence]}`}>
{enrichment.confidence} confidence
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{enrichment.provider} / {enrichment.model} ·{' '}
{new Date(enrichment.generatedAt).toLocaleString()}
</p>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Root Cause</h4>
<p className="text-sm">{enrichment.rootCause}</p>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">User Impact</h4>
<p className="text-sm">{enrichment.userImpact}</p>
</div>
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Suggested Fix</h4>
<p className="text-sm">{enrichment.suggestedFix}</p>
</div>
{enrichment.debugPrompt && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-1">Debug Prompt</h4>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto whitespace-pre-wrap">
{enrichment.debugPrompt}
</pre>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type { AnomalyEvidence } from '../../types'
interface EvidencePanelProps {
evidence: AnomalyEvidence
}
export function EvidencePanel({ evidence }: EvidencePanelProps) {
return (
<Tabs defaultValue="console">
<TabsList>
<TabsTrigger value="console">Console</TabsTrigger>
<TabsTrigger value="network">Network</TabsTrigger>
{evidence.domSnapshotPath && <TabsTrigger value="dom">DOM</TabsTrigger>}
</TabsList>
<TabsContent value="console" className="mt-3">
{evidence.rawErrors && evidence.rawErrors.length > 0 ? (
<ScrollArea className="h-48 rounded border bg-black/80 p-3 font-mono text-xs">
{evidence.rawErrors.map((err, i) => (
<div key={i} className="text-red-400 mb-1">{err}</div>
))}
</ScrollArea>
) : (
<p className="text-sm text-muted-foreground">No console errors captured.</p>
)}
</TabsContent>
<TabsContent value="network" className="mt-3">
{evidence.httpLog && evidence.httpLog.length > 0 ? (
<div className="rounded border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Method</TableHead>
<TableHead>Status</TableHead>
<TableHead>URL</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{evidence.httpLog.map((req, i) => (
<TableRow key={i}>
<TableCell className="font-mono text-xs">{req.method}</TableCell>
<TableCell className={`font-mono text-xs ${req.status >= 400 ? 'text-destructive' : ''}`}>
{req.status}
</TableCell>
<TableCell className="font-mono text-xs max-w-xs truncate">{req.url}</TableCell>
<TableCell className="text-xs text-muted-foreground">{req.durationMs}ms</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<p className="text-sm text-muted-foreground">No network requests captured.</p>
)}
</TabsContent>
{evidence.domSnapshotPath && (
<TabsContent value="dom" className="mt-3">
<div className="text-sm text-muted-foreground rounded border p-3">
<p>DOM snapshot: <code className="font-mono text-xs">{evidence.domSnapshotPath}</code></p>
</div>
</TabsContent>
)}
</Tabs>
)
}

View File

@@ -0,0 +1,62 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { Action } from '../../types'
interface ReproductionStepsProps {
steps: Action[]
}
const ACTION_ICONS: Record<string, string> = {
click: '🖱️',
fill: '⌨️',
navigate: '🔗',
scroll: '📜',
hover: '🎯',
select: '📋',
press: '⌨️',
}
export function ReproductionSteps({ steps }: ReproductionStepsProps) {
if (steps.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No action trace available.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Reproduction Steps</CardTitle>
</CardHeader>
<CardContent>
<ol className="space-y-2">
{steps.map((step, i) => (
<li key={step.id} className="flex gap-3 text-sm">
<span className="text-muted-foreground font-mono w-6 shrink-0 text-right">{i + 1}.</span>
<span className="shrink-0">{ACTION_ICONS[step.type] ?? '▶️'}</span>
<div className="flex-1 min-w-0">
<span className="font-medium">{step.type}</span>
{step.selector && (
<code className="ml-2 text-xs bg-muted px-1.5 py-0.5 rounded font-mono truncate block mt-0.5">
{step.selector}
</code>
)}
{step.value && (
<span className="text-muted-foreground ml-2 text-xs">
value: &quot;{step.value}&quot;
</span>
)}
</div>
</li>
))}
</ol>
</CardContent>
</Card>
)
}

View File

@@ -6,6 +6,7 @@ interface User {
email: string
name: string
role: string
orgId?: string
}
export function useAuth() {

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { AnomalySummary, Stats } from '../types'
import type { AnomalySummary, Finding, Stats } from '../types'
export function useFindings(params?: { sessionId?: string; severity?: string }) {
const qs = new URLSearchParams()
@@ -14,6 +14,14 @@ export function useFindings(params?: { sessionId?: string; severity?: string })
})
}
export function useFinding(id: string) {
return useQuery<Finding>({
queryKey: ['findings', id],
queryFn: () => apiFetch<Finding>(`/api/findings/${id}`),
enabled: !!id,
})
}
export function useFindingStats() {
return useQuery<Stats>({
queryKey: ['findings', 'stats'],

View File

@@ -0,0 +1,257 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { toast } from 'sonner'
interface Report {
id: string
title: string
format: 'html' | 'json' | 'pdf'
status: 'pending' | 'generating' | 'ready' | 'failed'
totalFindings: number
createdAt: string
completedAt: string | null
}
interface GenerateForm {
title: string
format: 'html' | 'json' | 'pdf'
sessionId: string
startDate: string
endDate: string
severity: string
}
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
pending: 'secondary',
generating: 'secondary',
ready: 'default',
failed: 'destructive',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString()
}
export function Reports() {
const qc = useQueryClient()
const [open, setOpen] = useState(false)
const [form, setForm] = useState<GenerateForm>({
title: '',
format: 'html',
sessionId: '',
startDate: '',
endDate: '',
severity: '',
})
const { data: reports = [], isLoading } = useQuery<Report[]>({
queryKey: ['reports'],
queryFn: () => apiFetch<Report[]>('/api/reports'),
refetchInterval: 5000,
})
const generate = useMutation({
mutationFn: (body: object) =>
apiFetch<{ reportId: string }>('/api/reports', {
method: 'POST',
body: JSON.stringify(body),
}),
onSuccess: () => {
toast.success('Report queued — it will be ready shortly')
setOpen(false)
void qc.invalidateQueries({ queryKey: ['reports'] })
},
onError: (e: Error) => toast.error(e.message),
})
function handleSubmit() {
if (!form.title) { toast.error('Title is required'); return }
const filters: Record<string, string> = {}
if (form.sessionId) filters['sessionId'] = form.sessionId
if (form.startDate) filters['startDate'] = form.startDate
if (form.endDate) filters['endDate'] = form.endDate
if (form.severity) filters['severity'] = form.severity
generate.mutate({ title: form.title, format: form.format, filters })
}
function handleDownload(report: Report) {
window.open(`/api/reports/${report.id}/download`, '_blank')
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Reports</h1>
<p className="text-sm text-muted-foreground mt-1">
Generate and download bug reports in multiple formats.
</p>
</div>
<Button onClick={() => setOpen(true)}>Generate Report</Button>
</div>
{isLoading ? (
<p className="text-muted-foreground text-sm">Loading...</p>
) : reports.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center border rounded-lg bg-muted/20">
<p className="text-muted-foreground">No reports yet.</p>
<p className="text-sm text-muted-foreground mt-1">Click "Generate Report" to create one.</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Format</TableHead>
<TableHead>Status</TableHead>
<TableHead>Findings</TableHead>
<TableHead>Created</TableHead>
<TableHead>Completed</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map(r => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.title}</TableCell>
<TableCell>
<Badge variant="outline">{r.format.toUpperCase()}</Badge>
</TableCell>
<TableCell>
<Badge variant={STATUS_COLORS[r.status] ?? 'outline'}>
{r.status}
</Badge>
</TableCell>
<TableCell>{r.status === 'ready' ? r.totalFindings : '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(r.createdAt)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{r.completedAt ? formatDate(r.completedAt) : '—'}
</TableCell>
<TableCell>
{r.status === 'ready' && (
<Button size="sm" variant="outline" onClick={() => handleDownload(r)}>
Download
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate Report</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Title</Label>
<Input
placeholder="e.g. Weekly Security Report"
value={form.title}
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>Format</Label>
<Select
value={form.format}
onValueChange={v => setForm(f => ({ ...f, format: v as GenerateForm['format'] }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="html">HTML</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Session ID (optional)</Label>
<Input
placeholder="Filter by session"
value={form.sessionId}
onChange={e => setForm(f => ({ ...f, sessionId: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>Start Date</Label>
<Input
type="date"
value={form.startDate}
onChange={e => setForm(f => ({ ...f, startDate: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label>End Date</Label>
<Input
type="date"
value={form.endDate}
onChange={e => setForm(f => ({ ...f, endDate: e.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label>Min Severity (optional)</Label>
<Select
value={form.severity || '_all'}
onValueChange={v => setForm(f => ({ ...f, severity: v === '_all' ? '' : v }))}
>
<SelectTrigger>
<SelectValue placeholder="All severities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All severities</SelectItem>
<SelectItem value="low">Low+</SelectItem>
<SelectItem value="medium">Medium+</SelectItem>
<SelectItem value="high">High+</SelectItem>
<SelectItem value="critical">Critical only</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={generate.isPending}>
{generate.isPending ? 'Queuing…' : 'Generate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,176 @@
import { useParams, useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Download, FileText, FileJson, Code2, CheckCircle2, Search, XCircle } from 'lucide-react'
import { SeverityBadge } from '@/components/common/SeverityBadge'
import { ReproductionSteps } from '@/components/findings/ReproductionSteps'
import { EvidencePanel } from '@/components/findings/EvidencePanel'
import { AIAnalysisPanel } from '@/components/findings/AIAnalysisPanel'
import { useFinding } from '@/hooks/useFindings'
import { apiFetch } from '@/lib/api'
import type { FindingStatus } from '../../types'
const STATUS_COLOR: Record<FindingStatus, string> = {
open: 'bg-red-500/15 text-red-500 border-red-500/30',
investigating: 'bg-yellow-500/15 text-yellow-600 border-yellow-500/30',
resolved: 'bg-green-500/15 text-green-600 border-green-500/30',
closed: 'bg-slate-500/15 text-slate-500 border-slate-500/30',
}
const STATUS_TRANSITIONS: Record<FindingStatus, Array<{ action: 'investigate' | 'resolve' | 'close'; label: string; icon: React.ElementType }>> = {
open: [
{ action: 'investigate', label: 'Investigate', icon: Search },
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
],
investigating: [
{ action: 'resolve', label: 'Resolve', icon: CheckCircle2 },
{ action: 'close', label: 'Close', icon: XCircle },
],
resolved: [
{ action: 'close', label: 'Close', icon: XCircle },
],
closed: [],
}
function downloadHref(findingId: string, format: 'markdown' | 'json' | 'playwright') {
const API_URL = import.meta.env.VITE_API_URL || ''
return `${API_URL}/api/findings/${findingId}/export/${format}`
}
export function FindingDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { data: finding, isLoading } = useFinding(id ?? '')
async function handleStatusChange(action: 'investigate' | 'resolve' | 'close') {
if (!id) return
try {
await apiFetch(`/api/findings/${id}/status`, {
method: 'PATCH',
body: JSON.stringify({ action }),
})
await queryClient.invalidateQueries({ queryKey: ['findings', id] })
await queryClient.invalidateQueries({ queryKey: ['findings'] })
} catch (e) {
console.error('Failed to update status:', e)
}
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!finding) {
return <div className="text-muted-foreground">Finding not found.</div>
}
const transitions = STATUS_TRANSITIONS[finding.status] ?? []
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/findings')} className="mt-0.5 shrink-0">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<SeverityBadge severity={finding.severity} />
<Badge variant="outline" className={STATUS_COLOR[finding.status]}>
{finding.status}
</Badge>
<span className="font-mono text-xs text-muted-foreground">{finding.type}</span>
</div>
<h1 className="text-lg font-bold mt-1 leading-snug">{finding.description}</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{finding.browser && <span>{finding.browser} · </span>}
Found {new Date(finding.createdAt).toLocaleString()}
{finding.resolvedAt && ` · Resolved ${new Date(finding.resolvedAt).toLocaleString()}`}
</p>
</div>
</div>
{/* Action bar */}
<div className="flex items-center gap-2 flex-wrap">
{/* Status workflow */}
{transitions.map(t => (
<Button
key={t.action}
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => void handleStatusChange(t.action)}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
</Button>
))}
<div className="flex-1" />
{/* Export buttons */}
<a href={downloadHref(finding.id, 'playwright')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<Code2 className="h-3.5 w-3.5" />
Playwright
</Button>
</a>
<a href={downloadHref(finding.id, 'markdown')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<FileText className="h-3.5 w-3.5" />
Markdown
</Button>
</a>
<a href={downloadHref(finding.id, 'json')} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm" className="gap-1.5">
<FileJson className="h-3.5 w-3.5" />
JSON
</Button>
</a>
<Button variant="ghost" size="sm" className="gap-1.5" disabled>
<Download className="h-3.5 w-3.5" />
Export
</Button>
</div>
{/* Main split layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Left: Evidence + Reproduction */}
<div className="space-y-4">
<Tabs defaultValue="evidence">
<TabsList>
<TabsTrigger value="evidence">Evidence</TabsTrigger>
<TabsTrigger value="steps">
Steps
{finding.actionTrace.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-xs">{finding.actionTrace.length}</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="evidence" className="mt-3">
<EvidencePanel evidence={finding.evidence} />
</TabsContent>
<TabsContent value="steps" className="mt-3">
<ReproductionSteps steps={finding.actionTrace} />
</TabsContent>
</Tabs>
</div>
{/* Right: AI Analysis */}
<div>
<AIAnalysisPanel findingId={finding.id} enrichment={finding.aiEnrichment} />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowUpDown, X } from 'lucide-react'
import { SeverityBadge } from '@/components/common/SeverityBadge'
import { useFindings } from '@/hooks/useFindings'
import type { AnomalySummary, Severity } from '../../types'
const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low']
const columns: ColumnDef<AnomalySummary>[] = [
{
accessorKey: 'severity',
header: 'Severity',
cell: ({ row }) => <SeverityBadge severity={row.original.severity} />,
},
{
accessorKey: 'type',
header: ({ column }) => (
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
Type <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => <span className="font-mono text-xs">{row.original.type}</span>,
},
{
accessorKey: 'description',
header: 'Description',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground max-w-sm block truncate">
{row.original.description}
</span>
),
},
{
accessorKey: 'timestamp',
header: ({ column }) => (
<button className="flex items-center gap-1 hover:text-foreground" onClick={() => column.toggleSorting()}>
Time <ArrowUpDown className="h-3 w-3" />
</button>
),
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(row.original.timestamp).toLocaleString()}
</span>
),
},
]
export function FindingsList() {
const navigate = useNavigate()
const [severity, setSeverity] = useState<string>('all')
const [search, setSearch] = useState('')
const [sorting, setSorting] = useState<SortingState>([{ id: 'timestamp', desc: true }])
const { data: allFindings = [], isLoading } = useFindings(
severity !== 'all' ? { severity } : undefined
)
const table = useReactTable({
data: allFindings,
columns,
state: { sorting, globalFilter: search },
onSortingChange: setSorting,
onGlobalFilterChange: setSearch,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Findings</h1>
<p className="text-sm text-muted-foreground">{table.getRowModel().rows.length} findings</p>
</div>
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<Input
placeholder="Search findings..."
value={search}
onChange={e => setSearch(e.target.value)}
className="max-w-xs"
/>
<Select value={severity} onValueChange={setSeverity}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Severity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All severities</SelectItem>
{SEVERITIES.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
{(severity !== 'all' || search) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setSeverity('all'); setSearch('') }}
className="gap-1"
>
<X className="h-3.5 w-3.5" />
Clear
</Button>
)}
</div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id}>
{hg.headers.map(h => (
<TableHead key={h.id}>
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center py-8 text-muted-foreground">
No findings yet. Start an exploration!
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className="cursor-pointer"
onClick={() => navigate(`/findings/${row.original.id}`)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Key, Trash2, Copy, CheckCircle2 } from 'lucide-react'
interface ApiKey {
id: string
name: string
keyPrefix: string
permissions: string[]
expiresAt: string | null
lastUsedAt: string | null
createdAt: string
}
interface CreatedKey {
id: string
name: string
keyPrefix: string
token: string
}
export function ApiKeysSection() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [submitting, setSubmitting] = useState(false)
const [created, setCreated] = useState<CreatedKey | null>(null)
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const { data: keys = [], isLoading } = useQuery<ApiKey[]>({
queryKey: ['api-keys'],
queryFn: () => apiFetch<ApiKey[]>('/api/auth/api-keys'),
})
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name) return
setSubmitting(true)
setError(null)
try {
const result = await apiFetch<CreatedKey>('/api/auth/api-keys', {
method: 'POST',
body: JSON.stringify({ name, permissions: ['*'] }),
})
setCreated(result)
setName('')
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
} catch (err) {
setError(err instanceof Error ? err.message : 'Create failed')
} finally {
setSubmitting(false)
}
}
async function handleRevoke(id: string) {
try {
await apiFetch(`/api/auth/api-keys/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['api-keys'] })
} catch (err) {
console.error('Revoke failed:', err)
}
}
function copyToken() {
if (created?.token) {
void navigator.clipboard.writeText(created.token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
function handleDialogChange(v: boolean) {
setOpen(v)
if (!v) {
setCreated(null)
setError(null)
}
}
return (
<div className="space-y-6 max-w-xl">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">API Keys</h2>
<p className="text-sm text-muted-foreground">Manage API keys for programmatic access.</p>
</div>
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Key className="h-3.5 w-3.5" />
New Key
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
{created ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Copy your API key now it won&apos;t be shown again.
</p>
<div className="flex items-center gap-2 p-2 rounded border bg-muted font-mono text-xs break-all">
<span className="flex-1">{created.token}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={copyToken}
>
{copied
? <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
: <Copy className="h-3.5 w-3.5" />
}
</Button>
</div>
<Button size="sm" onClick={() => setOpen(false)}>Done</Button>
</div>
) : (
<form onSubmit={handleCreate} className="space-y-3">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-1">
<Label htmlFor="key-name">Name</Label>
<Input
id="key-name"
placeholder="e.g. CI Pipeline"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Key'}
</Button>
</form>
)}
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : keys.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No API keys yet.</p>
) : (
<div className="space-y-2">
{keys.map(k => (
<div key={k.id} className="flex items-center gap-3 py-2 border-b last:border-0">
<Key className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{k.name}</p>
<p className="text-xs text-muted-foreground font-mono">{k.keyPrefix}***</p>
</div>
{k.lastUsedAt && (
<span className="text-xs text-muted-foreground">
Last used {new Date(k.lastUsedAt).toLocaleDateString()}
</span>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive shrink-0"
onClick={() => void handleRevoke(k.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { useTheme } from '@/components/layout/ThemeProvider'
import { Card, CardContent } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
export function AppearanceSection() {
const { theme, toggleTheme } = useTheme()
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Appearance</h2>
<p className="text-sm text-muted-foreground">Customize how ABE looks.</p>
</div>
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Dark Mode</Label>
<p className="text-xs text-muted-foreground">Toggle between dark and light theme.</p>
</div>
<Switch
checked={theme === 'dark'}
onCheckedChange={toggleTheme}
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { ServerConfig } from '@/types'
export function ExplorationDefaultsSection() {
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery<ServerConfig>({
queryKey: ['config'],
queryFn: () => apiFetch<ServerConfig>('/api/config'),
})
const [saving, setSaving] = useState(false)
const [maxStates, setMaxStates] = useState<number | undefined>()
const [maxDepth, setMaxDepth] = useState<number | undefined>()
const [actionDelayMs, setActionDelayMs] = useState<number | undefined>()
const effectiveMaxStates = maxStates ?? config?.defaultMaxStates ?? 50
const effectiveMaxDepth = maxDepth ?? config?.defaultMaxDepth ?? 5
const effectiveDelay = actionDelayMs ?? config?.defaultActionDelayMs ?? 500
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
try {
await apiFetch('/api/config', {
method: 'PATCH',
body: JSON.stringify({
defaultMaxStates: effectiveMaxStates,
defaultMaxDepth: effectiveMaxDepth,
defaultActionDelayMs: effectiveDelay,
}),
})
await queryClient.invalidateQueries({ queryKey: ['config'] })
} finally {
setSaving(false)
}
}
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Exploration Defaults</h2>
<p className="text-sm text-muted-foreground">Default values for new explorations.</p>
</div>
<Card>
<CardContent className="pt-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="max-states">Max States</Label>
<Input
id="max-states"
type="number"
min={1}
max={1000}
value={effectiveMaxStates}
onChange={e => setMaxStates(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Maximum number of states to explore per session.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="max-depth">Max Depth</Label>
<Input
id="max-depth"
type="number"
min={1}
max={20}
value={effectiveMaxDepth}
onChange={e => setMaxDepth(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">Maximum BFS depth of exploration.</p>
</div>
<div className="space-y-1">
<Label htmlFor="action-delay">Action Delay (ms)</Label>
<Input
id="action-delay"
type="number"
min={0}
max={5000}
value={effectiveDelay}
onChange={e => setActionDelayMs(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Delay between actions in milliseconds.
</p>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Saving...' : 'Save Defaults'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Shield } from 'lucide-react'
export function LicenseSection() {
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">License</h2>
<p className="text-sm text-muted-foreground">Manage your ABE license.</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle className="text-base">Current Plan</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Plan</span>
<Badge>Free / OSS</Badge>
</div>
<CardDescription>
License activation will be available in Phase 17 (RSA-signed keys with feature entitlements).
</CardDescription>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,98 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { ServerConfig } from '@/types'
export function NotificationsSection() {
const queryClient = useQueryClient()
const { data: config, isLoading } = useQuery<ServerConfig>({
queryKey: ['config'],
queryFn: () => apiFetch<ServerConfig>('/api/config'),
})
const [saving, setSaving] = useState(false)
const [webhookUrl, setWebhookUrl] = useState<string | undefined>()
const [minSeverity, setMinSeverity] = useState<string | undefined>()
const effectiveWebhook = webhookUrl ?? config?.slackWebhookUrl ?? ''
const effectiveMinSev = minSeverity ?? config?.notifyMinSeverity ?? 'high'
async function handleSave(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
try {
await apiFetch('/api/config', {
method: 'PATCH',
body: JSON.stringify({
slackWebhookUrl: effectiveWebhook || null,
notifyMinSeverity: effectiveMinSev,
}),
})
await queryClient.invalidateQueries({ queryKey: ['config'] })
} finally {
setSaving(false)
}
}
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">Configure Slack alerts for findings.</p>
</div>
<Card>
<CardContent className="pt-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="slack-webhook">Slack Webhook URL</Label>
<Input
id="slack-webhook"
type="url"
placeholder="https://hooks.slack.com/services/..."
value={effectiveWebhook}
onChange={e => setWebhookUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Leave empty to disable Slack notifications.
</p>
</div>
<div className="space-y-1">
<Label htmlFor="min-severity">Minimum Severity</Label>
<Select value={effectiveMinSev} onValueChange={setMinSeverity}>
<SelectTrigger id="min-severity">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Only send alerts for findings at or above this severity.
</p>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuth } from '@/hooks/useAuth'
import { apiFetch } from '@/lib/api'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { UserPlus } from 'lucide-react'
interface OrgMember {
userId: string
email: string
name: string
role: string
}
export function OrganizationSection() {
const { user } = useAuth()
const queryClient = useQueryClient()
const orgId = user?.orgId
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org', orgId, 'members'],
queryFn: () => apiFetch<OrgMember[]>(`/api/auth/organizations/${orgId}/members`),
enabled: !!orgId,
})
const [email, setEmail] = useState('')
const [role, setRole] = useState('member')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
async function handleInvite(e: React.FormEvent) {
e.preventDefault()
if (!orgId || !email) return
setSubmitting(true)
setError(null)
try {
await apiFetch(`/api/auth/organizations/${orgId}/members`, {
method: 'POST',
body: JSON.stringify({ email, role }),
})
setEmail('')
await queryClient.invalidateQueries({ queryKey: ['org', orgId, 'members'] })
} catch (err) {
setError(err instanceof Error ? err.message : 'Invite failed')
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Organization</h2>
<p className="text-sm text-muted-foreground">Manage members and roles.</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm">Members</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members yet.</p>
) : (
members.map(m => (
<div key={m.userId} className="flex items-center gap-3 py-1">
<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">
{m.name?.charAt(0).toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{m.name}</p>
<p className="text-xs text-muted-foreground truncate">{m.email}</p>
</div>
<Badge variant="outline" className="text-xs">{m.role}</Badge>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<UserPlus className="h-4 w-4" />
Invite Member
</CardTitle>
<CardDescription>Add a new member to your organization.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="space-y-3">
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-1">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
placeholder="colleague@example.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="invite-role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="invite-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? 'Inviting...' : 'Send Invite'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,60 @@
import { useAuth } from '@/hooks/useAuth'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
export function ProfileSection() {
const { user, isLoading } = useAuth()
if (isLoading) {
return (
<div className="space-y-4 max-w-xl">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
</div>
)
}
return (
<div className="space-y-6 max-w-xl">
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm text-muted-foreground">Your account information.</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback className="text-base">
{user?.name?.charAt(0).toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">{user?.name}</CardTitle>
<CardDescription>{user?.email}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant="secondary">{user?.role}</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">User ID</span>
<code className="text-xs font-mono text-muted-foreground">{user?.id}</code>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">Change Password</CardTitle>
<CardDescription>Password management coming in a future release.</CardDescription>
</CardHeader>
</Card>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ label: 'Profile', href: '/settings/profile', icon: User },
{ label: 'Organization', href: '/settings/organization', icon: Building },
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },
{ label: 'License', href: '/settings/license', icon: Shield },
]
export function SettingsLayout() {
return (
<div className="flex gap-8">
<nav className="w-48 shrink-0 space-y-1">
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide px-3 mb-3">
Settings
</h2>
{navItems.map(item => (
<NavLink
key={item.href}
to={item.href}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.label}
</NavLink>
))}
</nav>
<div className="flex-1 min-w-0">
<Outlet />
</div>
</div>
)
}

View File

@@ -88,6 +88,40 @@ export interface AnomalySummary {
browser?: BrowserType;
}
export type FindingStatus = 'open' | 'investigating' | 'resolved' | 'closed';
export interface Finding {
id: string;
sessionId: string;
type: string;
severity: Severity;
description: string;
status: FindingStatus;
browser?: BrowserType;
browserVersion?: string;
actionTraceLength: number;
actionTrace: Action[];
evidence: AnomalyEvidence;
aiEnrichment?: AIEnrichment | null;
createdAt: string;
resolvedAt?: string | null;
}
export interface FindingSummary {
id: string;
sessionId: string;
type: string;
severity: Severity;
description: string;
status: FindingStatus;
browser?: BrowserType;
actionTraceLength: number;
evidence: AnomalyEvidence;
aiEnrichment?: AIEnrichment | null;
createdAt: string;
resolvedAt?: string | null;
}
export interface Session {
sessionId: string;
url: string;