diff --git a/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx b/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx new file mode 100644 index 000000000..653b766b0 --- /dev/null +++ b/src/app/[orgId]/settings/logs/streaming/HttpDestinationCredenza.tsx @@ -0,0 +1,731 @@ +"use client"; + +import { useState, useEffect } from "react"; +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 { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Textarea } from "@app/components/ui/textarea"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Plus, X } from "lucide-react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { build } from "@server/build"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type AuthType = "none" | "bearer" | "basic" | "custom"; + +export 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; +} + +export interface Destination { + destinationId: number; + orgId: string; + type: string; + config: string; + enabled: boolean; + sendAccessLogs: boolean; + sendActionLogs: boolean; + sendConnectionLogs: boolean; + sendRequestLogs: boolean; + createdAt: number; + updatedAt: number; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +export const defaultHttpConfig = (): HttpConfig => ({ + name: "", + url: "", + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "", + headers: [], + useBodyTemplate: false, + bodyTemplate: "" +}); + +export function parseHttpConfig(raw: string): HttpConfig { + try { + return { ...defaultHttpConfig(), ...JSON.parse(raw) }; + } catch { + return defaultHttpConfig(); + } +} + +// ── 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" + /> + +
+ ))} + +
+ ); +} + +// ── Component ────────────────────────────────────────────────────────────────── + +export interface HttpDestinationCredenzaProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Destination | null; + orgId: string; + onSaved: () => void; +} + +export function HttpDestinationCredenza({ + open, + onOpenChange, + editing, + orgId, + onSaved +}: HttpDestinationCredenzaProps) { + const api = createApiClient(useEnvContext()); + + const [saving, setSaving] = useState(false); + const [cfg, setCfg] = useState(defaultHttpConfig()); + const [sendAccessLogs, setSendAccessLogs] = useState(false); + const [sendActionLogs, setSendActionLogs] = useState(false); + const [sendConnectionLogs, setSendConnectionLogs] = useState(false); + const [sendRequestLogs, setSendRequestLogs] = useState(false); + + useEffect(() => { + if (open) { + setCfg( + editing ? parseHttpConfig(editing.config) : defaultHttpConfig() + ); + setSendAccessLogs(editing?.sendAccessLogs ?? false); + setSendActionLogs(editing?.sendActionLogs ?? false); + setSendConnectionLogs(editing?.sendConnectionLogs ?? false); + setSendRequestLogs(editing?.sendRequestLogs ?? false); + } + }, [open, editing]); + + const update = (patch: Partial) => + setCfg((prev) => ({ ...prev, ...patch })); + + const urlError: string | null = (() => { + const raw = cfg.url.trim(); + if (!raw) return null; + try { + const parsed = new URL(raw); + if ( + parsed.protocol !== "http:" && + parsed.protocol !== "https:" + ) { + return "URL must use http or https"; + } + if (build === "saas" && parsed.protocol !== "https:") { + return "HTTPS is required on cloud deployments"; + } + return null; + } catch { + return "Enter a valid URL (e.g. https://example.com/webhook)"; + } + })(); + + const isValid = + cfg.name.trim() !== "" && + cfg.url.trim() !== "" && + urlError === null; + + async function handleSave() { + if (!isValid) return; + setSaving(true); + try { + const payload = { + type: "http", + config: JSON.stringify(cfg), + sendAccessLogs, + sendActionLogs, + sendConnectionLogs, + sendRequestLogs + }; + 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); + } + } + + return ( + + + + + {editing + ? "Edit Destination" + : "Add HTTP Destination"} + + + {editing + ? "Update the configuration for this HTTP event streaming destination." + : "Configure a new HTTP endpoint to receive your organization's events."} + + + + + + {/* ── Settings tab ────────────────────────────── */} +
+ {/* Name */} +
+ + + update({ name: e.target.value }) + } + /> +
+ + {/* URL */} +
+ + + update({ url: e.target.value }) + } + /> + {urlError && ( +

+ {urlError} +

+ )} +
+ + {/* Authentication */} +
+
+ +

+ Choose how requests to your endpoint + are authenticated. +

+
+ + + 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 tab ──────────────────────────────── */} +
+
+ +

+ Add custom headers to every outgoing + request. Useful for static tokens or + custom{" "} + + Content-Type + + . By default,{" "} + + Content-Type: application/json + {" "} + is sent. +

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

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

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