fase(16): integrations module

This commit is contained in:
Claude
2026-03-06 07:22:00 -05:00
committed by debian
parent cffa1aeea9
commit 1f1678af17
49 changed files with 2558 additions and 13 deletions

View File

@@ -19,6 +19,7 @@ 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 { IntegrationsSection } from '@/pages/settings/IntegrationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { Reports } from '@/pages/Reports'
@@ -57,6 +58,7 @@ export default function App() {
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="notifications" element={<NotificationsSection />} />
<Route path="integrations" element={<IntegrationsSection />} />
<Route path="appearance" element={<AppearanceSection />} />
<Route path="license" element={<LicenseSection />} />
</Route>

View File

@@ -0,0 +1,419 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import { Card, CardContent, 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 { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Trash2, Plus, Webhook, ExternalLink } from 'lucide-react'
import { toast } from 'sonner'
interface Integration {
id: string
name: string
type: 'slack' | 'github' | 'jira' | 'webhook'
enabled: boolean
config: Record<string, unknown>
createdAt: string
}
interface WebhookEndpoint {
id: string
url: string
enabled: boolean
createdAt: string
lastDeliveredAt: string | null
lastStatus: number | null
}
const TYPE_LABELS: Record<string, string> = {
slack: 'Slack',
github: 'GitHub Issues',
jira: 'Jira',
webhook: 'Custom Webhook',
}
export function IntegrationsSection() {
const queryClient = useQueryClient()
const { data: integrations = [], isLoading: loadingInt } = useQuery<Integration[]>({
queryKey: ['integrations'],
queryFn: () => apiFetch<Integration[]>('/api/integrations'),
})
const { data: webhooks = [], isLoading: loadingWebhooks } = useQuery<WebhookEndpoint[]>({
queryKey: ['webhooks'],
queryFn: () => apiFetch<WebhookEndpoint[]>('/api/integrations/webhooks/endpoints'),
})
const [addDialog, setAddDialog] = useState(false)
const [addWebhookDialog, setAddWebhookDialog] = useState(false)
const [newIntType, setNewIntType] = useState<string>('slack')
const [newIntName, setNewIntName] = useState('')
const [newIntConfig, setNewIntConfig] = useState<Record<string, string>>({})
const [newWebhookUrl, setNewWebhookUrl] = useState('')
const [saving, setSaving] = useState(false)
async function handleAddIntegration() {
setSaving(true)
try {
await apiFetch('/api/integrations', {
method: 'POST',
body: JSON.stringify({ name: newIntName, type: newIntType, config: newIntConfig }),
})
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
setAddDialog(false)
setNewIntName('')
setNewIntConfig({})
toast.success('Integration added')
} catch {
toast.error('Failed to add integration')
} finally {
setSaving(false)
}
}
async function handleToggle(integration: Integration) {
await apiFetch(`/api/integrations/${integration.id}`, {
method: 'PATCH',
body: JSON.stringify({ enabled: !integration.enabled }),
})
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
}
async function handleDelete(id: string) {
await apiFetch(`/api/integrations/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['integrations'] })
toast.success('Integration removed')
}
async function handleAddWebhook() {
setSaving(true)
try {
await apiFetch('/api/integrations/webhooks/endpoints', {
method: 'POST',
body: JSON.stringify({ url: newWebhookUrl }),
})
await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
setAddWebhookDialog(false)
setNewWebhookUrl('')
toast.success('Webhook endpoint added')
} catch {
toast.error('Failed to add webhook')
} finally {
setSaving(false)
}
}
async function handleDeleteWebhook(id: string) {
await apiFetch(`/api/integrations/webhooks/endpoints/${id}`, { method: 'DELETE' })
await queryClient.invalidateQueries({ queryKey: ['webhooks'] })
toast.success('Webhook endpoint removed')
}
const isLoading = loadingInt || loadingWebhooks
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<div className="space-y-8 max-w-2xl">
<div>
<h2 className="text-lg font-semibold">Integrations</h2>
<p className="text-sm text-muted-foreground">
Connect ABE to Slack, GitHub, Jira, or custom webhooks to receive findings automatically.
</p>
</div>
{/* Named integrations (Slack, GitHub, Jira) */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Configured Integrations</h3>
<Button size="sm" variant="outline" onClick={() => setAddDialog(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Integration
</Button>
</div>
{integrations.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No integrations configured. Add Slack, GitHub, or Jira to route findings automatically.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{integrations.map(integration => (
<Card key={integration.id}>
<CardContent className="py-3 px-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<ExternalLink className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{integration.name}</p>
<p className="text-xs text-muted-foreground">
{TYPE_LABELS[integration.type] ?? integration.type}
</p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<Badge variant={integration.enabled ? 'default' : 'secondary'}>
{integration.enabled ? 'Active' : 'Disabled'}
</Badge>
<Switch
checked={integration.enabled}
onCheckedChange={() => handleToggle(integration)}
/>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(integration.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Custom webhook endpoints */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Custom Webhook Endpoints</h3>
<Button size="sm" variant="outline" onClick={() => setAddWebhookDialog(true)}>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Endpoint
</Button>
</div>
<p className="text-xs text-muted-foreground -mt-2">
ABE sends a signed POST request to these URLs for every new finding. Verify with the{' '}
<code className="font-mono">X-ABE-Signature</code> header (HMAC-SHA256).
</p>
{webhooks.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No webhook endpoints configured.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{webhooks.map(ep => (
<Card key={ep.id}>
<CardContent className="py-3 px-4 flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Webhook className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-mono truncate">{ep.url}</p>
<p className="text-xs text-muted-foreground">
{ep.lastDeliveredAt
? `Last delivered: ${new Date(ep.lastDeliveredAt).toLocaleString()} — HTTP ${ep.lastStatus ?? '?'}`
: 'No deliveries yet'}
</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive shrink-0"
onClick={() => handleDeleteWebhook(ep.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Add Integration Dialog */}
<Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Integration</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Integration Type</Label>
<Select value={newIntType} onValueChange={t => { setNewIntType(t); setNewIntConfig({}) }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="github">GitHub Issues</SelectItem>
<SelectItem value="jira">Jira</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="int-name">Name</Label>
<Input
id="int-name"
placeholder="e.g. Security alerts"
value={newIntName}
onChange={e => setNewIntName(e.target.value)}
/>
</div>
{newIntType === 'slack' && (
<div className="space-y-1">
<Label htmlFor="slack-url">Webhook URL</Label>
<Input
id="slack-url"
type="url"
placeholder="https://hooks.slack.com/services/..."
value={(newIntConfig.webhookUrl ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, webhookUrl: e.target.value })}
/>
</div>
)}
{newIntType === 'github' && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="gh-token">Personal Access Token</Label>
<Input
id="gh-token"
type="password"
placeholder="ghp_..."
value={(newIntConfig.token ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, token: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="gh-repo">Repository (owner/repo)</Label>
<Input
id="gh-repo"
placeholder="myorg/myrepo"
value={(newIntConfig.repo ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, repo: e.target.value })}
/>
</div>
</div>
)}
{newIntType === 'jira' && (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="jira-host">Jira Host</Label>
<Input
id="jira-host"
type="url"
placeholder="https://yourorg.atlassian.net"
value={(newIntConfig.host ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, host: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-user">Username (email)</Label>
<Input
id="jira-user"
placeholder="user@example.com"
value={(newIntConfig.username ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, username: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-token">API Token</Label>
<Input
id="jira-token"
type="password"
value={(newIntConfig.token ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, token: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label htmlFor="jira-project">Project Key</Label>
<Input
id="jira-project"
placeholder="SEC"
value={(newIntConfig.projectKey ?? '') as string}
onChange={e => setNewIntConfig({ ...newIntConfig, projectKey: e.target.value })}
/>
</div>
</div>
)}
<Card className="border-dashed">
<CardHeader className="pb-2 pt-3 px-3">
<CardTitle className="text-xs text-muted-foreground">Minimum Severity</CardTitle>
</CardHeader>
<CardContent className="pb-3 px-3">
<Select
value={(newIntConfig.minSeverity ?? 'low') as string}
onValueChange={v => setNewIntConfig({ ...newIntConfig, minSeverity: v })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low+</SelectItem>
<SelectItem value="medium">Medium+</SelectItem>
<SelectItem value="high">High+</SelectItem>
<SelectItem value="critical">Critical only</SelectItem>
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)}>Cancel</Button>
<Button onClick={handleAddIntegration} disabled={saving || !newIntName}>
{saving ? 'Adding...' : 'Add Integration'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Webhook Endpoint Dialog */}
<Dialog open={addWebhookDialog} onOpenChange={setAddWebhookDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Webhook Endpoint</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label htmlFor="webhook-url">Endpoint URL</Label>
<Input
id="webhook-url"
type="url"
placeholder="https://your-server.com/webhooks/abe"
value={newWebhookUrl}
onChange={e => setNewWebhookUrl(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
ABE will POST a JSON payload signed with HMAC-SHA256 to this URL.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddWebhookDialog(false)}>Cancel</Button>
<Button onClick={handleAddWebhook} disabled={saving || !newWebhookUrl}>
{saving ? 'Adding...' : 'Add Endpoint'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield } from 'lucide-react'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
@@ -8,6 +8,7 @@ const navItems = [
{ 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: 'Integrations', href: '/settings/integrations', icon: Plug },
{ label: 'Appearance', href: '/settings/appearance', icon: Palette },
{ label: 'License', href: '/settings/license', icon: Shield },
]