Merge branch 'siem' into dev

This commit is contained in:
Owen
2026-03-31 14:20:46 -07:00
37 changed files with 3659 additions and 222 deletions

View File

@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import InvitationsTable, {
InvitationRow
} from "../../../../../components/InvitationsTable";
} from "@app/components/InvitationsTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";

View File

@@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { ListUsersResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import UsersTable, { UserRow } from "../../../../../components/UsersTable";
import UsersTable, { UserRow } from "@app/components/UsersTable";
import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import UserProvider from "@app/providers/UserProvider";
import { verifySession } from "@app/lib/auth/verifySession";
import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";

View File

@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, {
OrgApiKeyRow
} from "../../../../components/OrgApiKeysTable";
} from "@app/components/OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
import { getTranslations } from "next-intl/server";

View File

@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "../../../../components/DomainsTable";
import DomainsTable, { DomainRow } from "@app/components/DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";

View File

@@ -0,0 +1,478 @@
"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 ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
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 { Switch } from "@app/components/ui/switch";
import { Globe, MoreHorizontal, Plus } from "lucide-react";
import { AxiosResponse } from "axios";
import { build } from "@server/build";
import Image from "next/image";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Destination,
HttpDestinationCredenza,
parseHttpConfig
} from "@app/components/HttpDestinationCredenza";
// ── Re-export Destination so the rest of the file can use it ──────────────────
interface ListDestinationsResponse {
destinations: Destination[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
// ── Destination card ───────────────────────────────────────────────────────────
interface DestinationCardProps {
destination: Destination;
onToggle: (id: number, enabled: boolean) => void;
onEdit: (destination: Destination) => void;
onDelete: (destination: Destination) => void;
isToggling: boolean;
disabled?: boolean;
}
function DestinationCard({
destination,
onToggle,
onEdit,
onDelete,
isToggling,
disabled = false
}: DestinationCardProps) {
const cfg = parseHttpConfig(destination.config);
return (
<div className="relative flex flex-col rounded-lg border bg-card text-card-foreground p-5 gap-3">
{/* Top row: icon + name/type + toggle */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Squirkle icon: gray outer → white inner → black globe */}
<div className="shrink-0 flex items-center justify-center w-10 h-10 rounded-2xl bg-muted">
<div className="flex items-center justify-center w-6 h-6 rounded-xl bg-white shadow-sm">
<Globe className="h-3.5 w-3.5 text-black" />
</div>
</div>
<div className="min-w-0">
<p className="font-semibold text-sm leading-tight truncate">
{cfg.name || "Unnamed destination"}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
HTTP
</p>
</div>
</div>
<Switch
checked={destination.enabled}
onCheckedChange={(v) =>
onToggle(destination.destinationId, v)
}
disabled={isToggling || disabled}
className="shrink-0 mt-0.5"
/>
</div>
{/* URL preview */}
<p className="text-xs text-muted-foreground truncate">
{cfg.url || (
<span className="italic">No URL configured</span>
)}
</p>
{/* Footer: edit button + three-dots menu */}
<div className="mt-auto pt-5 flex gap-2">
<Button
variant="outline"
onClick={() => onEdit(destination)}
disabled={disabled}
className="flex-1"
>
Edit
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
disabled={disabled}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onDelete(destination)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
// ── Add destination card ───────────────────────────────────────────────────────
function AddDestinationCard({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-transparent transition-colors p-5 min-h-35 w-full text-muted-foreground hover:border-primary hover:text-primary hover:bg-primary/5 cursor-pointer"
>
<div className="flex flex-col items-center gap-2">
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
<Plus className="h-4 w-4" />
</div>
<span className="text-sm font-medium">Add Destination</span>
</div>
</button>
);
}
// ── Destination type picker ────────────────────────────────────────────────────
type DestinationType = "http" | "s3" | "datadog";
const destinationTypeOptions: ReadonlyArray<StrategyOption<DestinationType>> = [
{
id: "http",
title: "HTTP Webhook",
description:
"Send events to any HTTP endpoint with flexible authentication and templating.",
icon: <Globe className="h-6 w-6" />
},
{
id: "s3",
title: "Amazon S3",
description:
"Stream events to an S3-compatible object storage bucket. Coming soon.",
disabled: true,
icon: (
<Image
src="/third-party/s3.png"
alt="Amazon S3"
width={24}
height={24}
className="rounded-sm"
/>
)
},
{
id: "datadog",
title: "Datadog",
description:
"Forward events directly to your Datadog account. Coming soon.",
disabled: true,
icon: (
<Image
src="/third-party/dd.png"
alt="Datadog"
width={24}
height={24}
className="rounded-sm"
/>
)
}
];
interface DestinationTypePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (type: DestinationType) => void;
isPaywalled?: boolean;
}
function DestinationTypePicker({
open,
onOpenChange,
onSelect,
isPaywalled = false
}: DestinationTypePickerProps) {
const [selected, setSelected] = useState<DestinationType>("http");
useEffect(() => {
if (open) setSelected("http");
}, [open]);
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-lg">
<CredenzaHeader>
<CredenzaTitle>Add Destination</CredenzaTitle>
<CredenzaDescription>
Choose a destination type to get started.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className={isPaywalled ? "pointer-events-none opacity-50" : ""}>
<StrategySelect
options={destinationTypeOptions}
value={selected}
onChange={setSelected}
cols={1}
/>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button
onClick={() => onSelect(selected)}
disabled={isPaywalled}
>
Continue
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
// ── Main page ──────────────────────────────────────────────────────────────────
export default function StreamingDestinationsPage() {
const { orgId } = useParams() as { orgId: string };
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isEnterprise = isPaidUser(tierMatrix[TierFeature.SIEM]);
const [destinations, setDestinations] = useState<Destination[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [typePickerOpen, setTypePickerOpen] = useState(false);
const [editingDestination, setEditingDestination] =
useState<Destination | null>(null);
const [togglingIds, setTogglingIds] = useState<Set<number>>(new Set());
// Delete state
const [deleteTarget, setDeleteTarget] = useState<Destination | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const loadDestinations = useCallback(async () => {
if (build == "oss") {
setDestinations([]);
setLoading(false);
return;
}
try {
const res = await api.get<AxiosResponse<ListDestinationsResponse>>(
`/org/${orgId}/event-streaming-destinations`
);
setDestinations(res.data.data.destinations ?? []);
} catch (e) {
toast({
variant: "destructive",
title: "Failed to load destinations",
description: formatAxiosError(
e,
"An unexpected error occurred."
)
});
} finally {
setLoading(false);
}
}, [orgId]);
useEffect(() => {
loadDestinations();
}, [loadDestinations]);
const handleToggle = async (destinationId: number, enabled: boolean) => {
// Optimistic update
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId ? { ...d, enabled } : d
)
);
setTogglingIds((prev) => new Set(prev).add(destinationId));
try {
await api.post(
`/org/${orgId}/event-streaming-destination/${destinationId}`,
{ enabled }
);
} catch (e) {
// Revert on failure
setDestinations((prev) =>
prev.map((d) =>
d.destinationId === destinationId
? { ...d, enabled: !enabled }
: d
)
);
toast({
variant: "destructive",
title: "Failed to update destination",
description: formatAxiosError(
e,
"An unexpected error occurred."
)
});
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(destinationId);
return next;
});
}
};
const handleDeleteCard = (destination: Destination) => {
setDeleteTarget(destination);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await api.delete(
`/org/${orgId}/event-streaming-destination/${deleteTarget.destinationId}`
);
toast({ title: "Destination deleted successfully" });
setDeleteDialogOpen(false);
setDeleteTarget(null);
loadDestinations();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to delete destination",
description: formatAxiosError(
e,
"An unexpected error occurred."
)
});
} finally {
setDeleting(false);
}
};
const openCreate = () => {
setTypePickerOpen(true);
};
const handleTypePicked = (_type: DestinationType) => {
setTypePickerOpen(false);
setEditingDestination(null);
setModalOpen(true);
};
const openEdit = (destination: Destination) => {
setEditingDestination(destination);
setModalOpen(true);
};
return (
<>
<SettingsSectionTitle
title="Event Streaming"
description="Stream events from your organization to external destinations in real time."
/>
<PaidFeaturesAlert tiers={tierMatrix[TierFeature.SIEM]} />
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-lg border bg-card p-5 min-h-36 animate-pulse"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{destinations.map((dest) => (
<DestinationCard
key={dest.destinationId}
destination={dest}
onToggle={handleToggle}
onEdit={openEdit}
onDelete={handleDeleteCard}
isToggling={togglingIds.has(dest.destinationId)}
disabled={!isEnterprise}
/>
))}
{/* Add card is always clickable — paywall is enforced inside the picker */}
<AddDestinationCard onClick={openCreate} />
</div>
)}
<DestinationTypePicker
open={typePickerOpen}
onOpenChange={setTypePickerOpen}
onSelect={handleTypePicked}
isPaywalled={!isEnterprise}
/>
<HttpDestinationCredenza
open={modalOpen}
onOpenChange={setModalOpen}
editing={editingDestination}
orgId={orgId}
onSaved={loadDestinations}
/>
{deleteTarget && (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={(v) => {
setDeleteDialogOpen(v);
if (!v) setDeleteTarget(null);
}}
string={
parseHttpConfig(deleteTarget.config).name || "delete"
}
title="Delete Destination"
dialog={
<p className="text-sm text-muted-foreground">
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">
{parseHttpConfig(deleteTarget.config).name ||
"this destination"}
</span>
? All configuration will be permanently removed.
</p>
}
buttonText="Delete Destination"
onConfirm={handleDeleteConfirm}
/>
)}
</>
);
}

View File

@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SiteProvisioningKeysTable, {
SiteProvisioningKeyRow
} from "../../../../../components/SiteProvisioningKeysTable";
} from "@app/components/SiteProvisioningKeysTable";
import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { getTranslations } from "next-intl/server";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";

View File

@@ -9,7 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
import { ListAccessTokensResponse } from "@server/routers/accessToken";
import ShareLinksTable, {
ShareLinkRow
} from "../../../../components/ShareLinksTable";
} from "@app/components/ShareLinksTable";
import { getTranslations } from "next-intl/server";
type ShareLinksPageProps = {

View File

@@ -6,9 +6,9 @@ import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SiteInfoCard from "../../../../../components/SiteInfoCard";
import SiteInfoCard from "@app/components/SiteInfoCard";
import { getTranslations } from "next-intl/server";
import { build } from "@server/build";
interface SettingsLayoutProps {
children: React.ReactNode;

View File

@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
import ApiKeysTable, { ApiKeyRow } from "../../../components/ApiKeysTable";
import ApiKeysTable, { ApiKeyRow } from "@app/components/ApiKeysTable";
import { getTranslations } from "next-intl/server";
type ApiKeyPageProps = {};

View File

@@ -31,7 +31,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable";
import PolicyTable, { PolicyRow } from "@app/components/PolicyTable";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { ListRolesResponse } from "@server/routers/role";

View File

@@ -2,7 +2,7 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import IdpTable, { IdpRow } from "../../../components/AdminIdpTable";
import IdpTable, { IdpRow } from "@app/components/AdminIdpTable";
import { getTranslations } from "next-intl/server";
export default async function IdpPage() {

View File

@@ -6,7 +6,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { LicenseKeysDataTable } from "../../../components/LicenseKeysDataTable";
import { LicenseKeysDataTable } from "@app/components/LicenseKeysDataTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import {
@@ -45,7 +45,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Check, Heart, InfoIcon } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "../../../components/SitePriceCalculator";
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";

View File

@@ -3,7 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
import UsersTable, { GlobalUserRow } from "../../../components/AdminUsersTable";
import UsersTable, { GlobalUserRow } from "@app/components/AdminUsersTable";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
import { getTranslations } from "next-intl/server";

View File

@@ -23,6 +23,7 @@ import {
Settings,
SquareMousePointer,
TicketCheck,
Unplug,
User,
UserCog,
Users,
@@ -196,6 +197,11 @@ export const orgNavSections = (
title: "sidebarLogsConnection",
href: "/{orgId}/settings/logs/connection",
icon: <Cable className="size-4 flex-none" />
},
{
title: "sidebarLogsStreaming",
href: "/{orgId}/settings/logs/streaming",
icon: <Unplug className="size-4 flex-none" />
}
]
: [])

View File

@@ -0,0 +1,836 @@
"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 type PayloadFormat = "json_array" | "ndjson" | "json_single";
export interface HttpConfig {
name: string;
url: string;
authType: AuthType;
bearerToken?: string;
basicCredentials?: string;
customHeaderName?: string;
customHeaderValue?: string;
headers: Array<{ key: string; value: string }>;
format: PayloadFormat;
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: [],
format: "json_array",
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 (
<div className="space-y-3">
{headers.length === 0 && (
<p className="text-xs text-muted-foreground">
No custom headers configured. Click "Add Header" to add
one.
</p>
)}
{headers.map((h, i) => (
<div key={i} className="flex gap-2 items-center">
<Input
value={h.key}
onChange={(e) => updateRow(i, "key", e.target.value)}
placeholder="Header name"
className="flex-1"
/>
<Input
value={h.value}
onChange={(e) =>
updateRow(i, "value", e.target.value)
}
placeholder="Value"
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRow(i)}
className="shrink-0 h-9 w-9"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add Header
</Button>
</div>
);
}
// ── 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<HttpConfig>(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<HttpConfig>) =>
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 (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent className="sm:max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>
{editing
? "Edit Destination"
: "Add HTTP Destination"}
</CredenzaTitle>
<CredenzaDescription>
{editing
? "Update the configuration for this HTTP event streaming destination."
: "Configure a new HTTP endpoint to receive your organization's events."}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<HorizontalTabs
clientSide
items={[
{ title: "Settings", href: "" },
{ title: "Headers", href: "" },
{ title: "Body", href: "" },
{ title: "Logs", href: "" }
]}
>
{/* ── Settings tab ────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="dest-name">Name</Label>
<Input
id="dest-name"
placeholder="My HTTP destination"
value={cfg.name}
onChange={(e) =>
update({ name: e.target.value })
}
/>
</div>
{/* URL */}
<div className="space-y-2">
<Label htmlFor="dest-url">
Destination URL
</Label>
<Input
id="dest-url"
placeholder="https://example.com/webhook"
value={cfg.url}
onChange={(e) =>
update({ url: e.target.value })
}
/>
{urlError && (
<p className="text-xs text-destructive">
{urlError}
</p>
)}
</div>
{/* Authentication */}
<div className="space-y-3">
<div>
<label className="font-medium block">
Authentication
</label>
<p className="text-sm text-muted-foreground mt-0.5">
Choose how requests to your endpoint
are authenticated.
</p>
</div>
<RadioGroup
value={cfg.authType}
onValueChange={(v) =>
update({ authType: v as AuthType })
}
className="gap-2"
>
{/* None */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="none"
id="auth-none"
className="mt-0.5"
/>
<div>
<Label
htmlFor="auth-none"
className="cursor-pointer font-medium"
>
No Authentication
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Sends requests without an{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
Authorization
</code>{" "}
header.
</p>
</div>
</div>
{/* Bearer */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="bearer"
id="auth-bearer"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-bearer"
className="cursor-pointer font-medium"
>
Bearer Token
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Adds an{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
Authorization: Bearer
&lt;token&gt;
</code>{" "}
header to each request.
</p>
</div>
{cfg.authType === "bearer" && (
<Input
placeholder="Your API key or token"
value={
cfg.bearerToken ?? ""
}
onChange={(e) =>
update({
bearerToken:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Basic */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="basic"
id="auth-basic"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-basic"
className="cursor-pointer font-medium"
>
Basic Auth
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Adds an{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
Authorization: Basic
&lt;credentials&gt;
</code>{" "}
header. Provide credentials
as{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
username:password
</code>
.
</p>
</div>
{cfg.authType === "basic" && (
<Input
placeholder="username:password"
value={
cfg.basicCredentials ??
""
}
onChange={(e) =>
update({
basicCredentials:
e.target.value
})
}
/>
)}
</div>
</div>
{/* Custom */}
<div className="flex items-start gap-3 rounded-md border p-3">
<RadioGroupItem
value="custom"
id="auth-custom"
className="mt-0.5"
/>
<div className="flex-1 space-y-3">
<div>
<Label
htmlFor="auth-custom"
className="cursor-pointer font-medium"
>
Custom Header
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Specify a custom HTTP
header name and value for
authentication (e.g.{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
X-API-Key
</code>
).
</p>
</div>
{cfg.authType === "custom" && (
<div className="flex gap-2">
<Input
placeholder="Header name (e.g. X-API-Key)"
value={
cfg.customHeaderName ??
""
}
onChange={(e) =>
update({
customHeaderName:
e.target
.value
})
}
className="flex-1"
/>
<Input
placeholder="Header value"
value={
cfg.customHeaderValue ??
""
}
onChange={(e) =>
update({
customHeaderValue:
e.target
.value
})
}
className="flex-1"
/>
</div>
)}
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Headers tab ──────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
Custom HTTP Headers
</label>
<p className="text-sm text-muted-foreground mt-0.5">
Add custom headers to every outgoing
request. Useful for static tokens or
custom{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
Content-Type
</code>
. By default,{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
Content-Type: application/json
</code>{" "}
is sent.
</p>
</div>
<HeadersEditor
headers={cfg.headers}
onChange={(headers) => update({ headers })}
/>
</div>
{/* ── Body tab ─────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
Custom Body Template
</label>
<p className="text-sm text-muted-foreground mt-0.5">
Control the JSON payload structure sent to
your endpoint. If disabled, a default JSON
object is sent for each event.
</p>
</div>
<div className="flex items-center gap-3">
<Switch
id="use-body-template"
checked={cfg.useBodyTemplate}
onCheckedChange={(v) =>
update({ useBodyTemplate: v })
}
/>
<Label
htmlFor="use-body-template"
className="cursor-pointer"
>
Enable custom body template
</Label>
</div>
{cfg.useBodyTemplate && (
<div className="space-y-2">
<Label htmlFor="body-template">
Body Template (JSON)
</Label>
<Textarea
id="body-template"
placeholder={
'{\n "event": "{{event}}",\n "timestamp": "{{timestamp}}",\n "data": {{data}}\n}'
}
value={cfg.bodyTemplate ?? ""}
onChange={(e) =>
update({
bodyTemplate: e.target.value
})
}
className="font-mono text-xs min-h-45 resize-y"
/>
<p className="text-xs text-muted-foreground">
Use template variables to reference
event fields in your payload.
</p>
</div>
)}
{/* Payload Format */}
<div className="space-y-3">
<div>
<label className="font-medium block">
Payload Format
</label>
<p className="text-sm text-muted-foreground mt-0.5">
How events are serialised into each
request body.
</p>
</div>
<RadioGroup
value={cfg.format ?? "json_array"}
onValueChange={(v) =>
update({
format: v as PayloadFormat
})
}
className="gap-2"
>
{/* JSON Array */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_array"
id="fmt-json-array"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-array"
className="cursor-pointer font-medium"
>
JSON Array
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
One request per batch, body is
a JSON array{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs">
[{"{...}"}, {"{...}"}]
</code>
. Compatible with most generic
webhooks and Datadog.
</p>
</div>
</div>
{/* NDJSON */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="ndjson"
id="fmt-ndjson"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-ndjson"
className="cursor-pointer font-medium"
>
NDJSON
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
One request per batch, body is
newline-delimited JSON one
object per line, no outer
array. Required by{" "}
<strong>Splunk HEC</strong>,{" "}
<strong>
Elastic / OpenSearch
</strong>
, and{" "}
<strong>Grafana Loki</strong>.
</p>
</div>
</div>
{/* Single event per request */}
<div className="flex items-start gap-3 rounded-md border p-3 transition-colors">
<RadioGroupItem
value="json_single"
id="fmt-json-single"
className="mt-0.5"
/>
<div>
<Label
htmlFor="fmt-json-single"
className="cursor-pointer font-medium"
>
One Event Per Request
</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Sends a separate HTTP POST for
each individual event. Use only
for endpoints that cannot
handle batches.
</p>
</div>
</div>
</RadioGroup>
</div>
</div>
{/* ── Logs tab ──────────────────────────────────── */}
<div className="space-y-6 mt-4 p-1">
<div>
<label className="font-medium block">
Log Types
</label>
<p className="text-sm text-muted-foreground mt-0.5">
Choose which log types are forwarded to
this destination. Only enabled log types
will be streamed.
</p>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-access"
checked={sendAccessLogs}
onCheckedChange={(v) =>
setSendAccessLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-access"
className="text-sm font-medium cursor-pointer"
>
Access Logs
</label>
<p className="text-xs text-muted-foreground mt-0.5">
Resource access attempts, including
authenticated and denied requests.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-action"
checked={sendActionLogs}
onCheckedChange={(v) =>
setSendActionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-action"
className="text-sm font-medium cursor-pointer"
>
Action Logs
</label>
<p className="text-xs text-muted-foreground mt-0.5">
Administrative actions performed by
users within the organization.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-connection"
checked={sendConnectionLogs}
onCheckedChange={(v) =>
setSendConnectionLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-connection"
className="text-sm font-medium cursor-pointer"
>
Connection Logs
</label>
<p className="text-xs text-muted-foreground mt-0.5">
Site and tunnel connection events,
including connects and
disconnects.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border p-3">
<Checkbox
id="log-request"
checked={sendRequestLogs}
onCheckedChange={(v) =>
setSendRequestLogs(v === true)
}
className="mt-0.5"
/>
<div>
<label
htmlFor="log-request"
className="text-sm font-medium cursor-pointer"
>
Request Logs
</label>
<p className="text-xs text-muted-foreground mt-0.5">
HTTP request logs for proxied
resources, including method, path,
and response code.
</p>
</div>
</div>
</div>
</div>
</HorizontalTabs>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
type="button"
variant="outline"
disabled={saving}
>
Cancel
</Button>
</CredenzaClose>
<Button
type="button"
onClick={handleSave}
loading={saving}
disabled={!isValid || saving}
>
{editing ? "Save Changes" : "Create Destination"}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}