diff --git a/messages/en-US.json b/messages/en-US.json index 6a137e2ba..e8c7cb47d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2460,6 +2460,7 @@ "connectionLogs": "Connection Logs", "connectionLogsDescription": "View connection logs for tunnels in this organization", "sidebarLogsConnection": "Connection Logs", + "sidebarLogsStreaming": "Streaming", "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 2aa38e1ef..c76dcd95b 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -18,7 +18,8 @@ export enum TierFeature { AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning SshPam = "sshPam", FullRbac = "fullRbac", - SiteProvisioningKeys = "siteProvisioningKeys" // handle downgrade by revoking keys if needed + SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed + SIEM = "siem" // handle downgrade by disabling SIEM integrations } export const tierMatrix: Record = { @@ -54,5 +55,6 @@ export const tierMatrix: Record = { [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], - [TierFeature.SiteProvisioningKeys]: ["enterprise"] + [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], + [TierFeature.SIEM]: ["enterprise"] }; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 41a4919a0..4410a44c8 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -648,7 +648,6 @@ authenticated.delete( authenticated.get( "/org/:orgId/event-streaming-destinations", - verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), eventStreamingDestination.listEventStreamingDestinations diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx new file mode 100644 index 000000000..265001e8e --- /dev/null +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -0,0 +1,861 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { Switch } from "@app/components/ui/switch"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@app/components/ui/tabs"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Textarea } from "@app/components/ui/textarea"; +import { Globe, Plus, Pencil, Trash2, X } from "lucide-react"; +import { AxiosResponse } from "axios"; +import { build } from "@server/build"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type AuthType = "none" | "bearer" | "basic" | "custom"; + +interface HttpConfig { + name: string; + url: string; + authType: AuthType; + bearerToken?: string; + basicCredentials?: string; + customHeaderName?: string; + customHeaderValue?: string; + headers: Array<{ key: string; value: string }>; + useBodyTemplate: boolean; + bodyTemplate?: string; +} + +interface Destination { + destinationId: number; + orgId: string; + type: string; + config: string; + enabled: boolean; + createdAt: number; + updatedAt: number; +} + +interface ListDestinationsResponse { + destinations: Destination[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const defaultConfig = (): HttpConfig => ({ + name: "", + url: "", + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "", + headers: [], + useBodyTemplate: false, + bodyTemplate: "" +}); + +function parseConfig(raw: string): HttpConfig { + try { + return { ...defaultConfig(), ...JSON.parse(raw) }; + } catch { + return defaultConfig(); + } +} + +// ── Headers editor ───────────────────────────────────────────────────────────── + +interface HeadersEditorProps { + headers: Array<{ key: string; value: string }>; + onChange: (headers: Array<{ key: string; value: string }>) => void; +} + +function HeadersEditor({ headers, onChange }: HeadersEditorProps) { + const addRow = () => onChange([...headers, { key: "", value: "" }]); + + const removeRow = (i: number) => + onChange(headers.filter((_, idx) => idx !== i)); + + const updateRow = ( + i: number, + field: "key" | "value", + val: string + ) => { + const next = [...headers]; + next[i] = { ...next[i], [field]: val }; + onChange(next); + }; + + return ( +
+ {headers.length === 0 && ( +

+ No custom headers configured. Click "Add Header" to add one. +

+ )} + {headers.map((h, i) => ( +
+ updateRow(i, "key", e.target.value)} + placeholder="Header name" + className="flex-1" + /> + updateRow(i, "value", e.target.value)} + placeholder="Value" + className="flex-1" + /> + +
+ ))} + +
+ ); +} + +// ── Destination card ─────────────────────────────────────────────────────────── + +interface DestinationCardProps { + destination: Destination; + onToggle: (id: number, enabled: boolean) => void; + onEdit: (destination: Destination) => void; + isToggling: boolean; + disabled?: boolean; +} + +function DestinationCard({ + destination, + onToggle, + onEdit, + isToggling, + disabled = false +}: DestinationCardProps) { + const cfg = parseConfig(destination.config); + + return ( +
+ {/* Top row: icon + name/type + toggle */} +
+
+
+ +
+
+

+ {cfg.name || "Unnamed destination"} +

+

+ HTTP +

+
+
+ + onToggle(destination.destinationId, v) + } + disabled={isToggling || disabled} + className="shrink-0 mt-0.5" + /> +
+ + {/* URL preview */} +

+ {cfg.url || ( + No URL configured + )} +

+ + {/* Footer: edit button */} +
+ +
+
+ ); +} + +// ── Add destination card ─────────────────────────────────────────────────────── + +function AddDestinationCard({ + onClick, + disabled = false +}: { + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ── Destination modal ────────────────────────────────────────────────────────── + +interface DestinationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Destination | null; + orgId: string; + onSaved: () => void; + onDeleted: () => void; +} + +function DestinationModal({ + open, + onOpenChange, + editing, + orgId, + onSaved, + onDeleted +}: DestinationModalProps) { + const api = createApiClient(useEnvContext()); + + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [cfg, setCfg] = useState(defaultConfig()); + + useEffect(() => { + if (open) { + setCfg(editing ? parseConfig(editing.config) : defaultConfig()); + setConfirmDelete(false); + } + }, [open, editing]); + + const update = (patch: Partial) => + setCfg((prev) => ({ ...prev, ...patch })); + + const isValid = + cfg.name.trim() !== "" && cfg.url.trim() !== ""; + + async function handleSave() { + if (!isValid) return; + setSaving(true); + try { + const payload = { + type: "http", + config: JSON.stringify(cfg) + }; + if (editing) { + await api.post( + `/org/${orgId}/event-streaming-destination/${editing.destinationId}`, + payload + ); + toast({ title: "Destination updated successfully" }); + } else { + await api.put( + `/org/${orgId}/event-streaming-destination`, + payload + ); + toast({ title: "Destination created successfully" }); + } + onSaved(); + onOpenChange(false); + } catch (e) { + toast({ + variant: "destructive", + title: editing + ? "Failed to update destination" + : "Failed to create destination", + description: formatAxiosError( + e, + "An unexpected error occurred." + ) + }); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!editing) return; + if (!confirmDelete) { + setConfirmDelete(true); + return; + } + setDeleting(true); + try { + await api.delete( + `/org/${orgId}/event-streaming-destination/${editing.destinationId}` + ); + toast({ title: "Destination deleted successfully" }); + onDeleted(); + onOpenChange(false); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to delete destination", + description: formatAxiosError( + e, + "An unexpected error occurred." + ) + }); + } finally { + setDeleting(false); + } + } + + return ( + + + + + {editing ? "Edit Destination" : "Add Destination"} + + + {editing + ? "Update the configuration for this HTTP event streaming destination." + : "Configure a new HTTP endpoint to receive your organization's events."} + + + + + + + Settings + Headers + Body Template + + + {/* ── Settings ─────────────────────────────────── */} + +
+ + + update({ name: e.target.value }) + } + /> +
+ +
+ + + update({ url: e.target.value }) + } + /> +
+ +
+ + + update({ authType: v as AuthType }) + } + className="gap-2" + > + {/* None */} +
+ +
+ +

+ Sends requests without an{" "} + + Authorization + {" "} + header. +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ Adds an{" "} + + Authorization: Bearer <token> + {" "} + header to each request. +

+
+ {cfg.authType === "bearer" && ( + + update({ + bearerToken: + e.target.value + }) + } + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ Adds an{" "} + + Authorization: Basic <credentials> + {" "} + header. Provide credentials as{" "} + + username:password + + . +

+
+ {cfg.authType === "basic" && ( + + update({ + basicCredentials: + e.target.value + }) + } + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ Specify a custom HTTP header name and value for + authentication (e.g.{" "} + + X-API-Key + + ). +

+
+ {cfg.authType === "custom" && ( +
+ + update({ + customHeaderName: + e.target.value + }) + } + className="flex-1" + /> + + update({ + customHeaderValue: + e.target.value + }) + } + className="flex-1" + /> +
+ )} +
+
+
+
+
+ + {/* ── Headers ───────────────────────────────────── */} + +
+

+ Custom HTTP Headers +

+

+ Add custom HTTP headers to every outgoing request. + Useful for passing static tokens, setting a custom{" "} + + Content-Type + + , or other API requirements. By default, the{" "} + + Content-Type + {" "} + is{" "} + + application/json + + . +

+ update({ headers })} + /> +
+
+ + {/* ── Body Template ─────────────────────────────── */} + +
+

+ Custom Body Template +

+

+ Control the structure of the JSON payload sent to your + endpoint. If disabled, a default JSON object is sent for + each event. +

+
+ +
+ + update({ useBodyTemplate: v }) + } + /> + +
+ + {cfg.useBodyTemplate && ( +
+ +