fase(16): integrations module
This commit is contained in:
@@ -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>
|
||||
|
||||
419
frontend/src/pages/settings/IntegrationsSection.tsx
Normal file
419
frontend/src/pages/settings/IntegrationsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user