From 78f6d9125539a932bedc42fb8b3426b34f21a299 Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 5 Feb 2026 17:19:12 -0600 Subject: [PATCH 1/2] webhook admin ui clean up --- .../requests/WebhookRequestsContent.tsx | 155 +++++++++--- .../OrganizationAdminDashboard.tsx | 4 + .../OrganizationAdminWebhooks.tsx | 31 +++ .../UserAdmin/UserAdminAccountInfo.tsx | 7 + .../[id]/webhooks/[triggerId]/page.tsx | 20 ++ .../organizations/[id]/webhooks/page.tsx | 41 ++++ .../users/[id]/webhooks/[triggerId]/page.tsx | 18 ++ src/app/admin/users/[id]/webhooks/page.tsx | 46 ++++ .../webhooks/AdminWebhookTriggerDetails.tsx | 222 ++++++++++++++++++ .../webhooks/AdminWebhookTriggersList.tsx | 157 +++++++++++++ .../webhook-triggers/TriggersTable.tsx | 123 +++++++--- .../WebhookTriggersHeader.tsx | 32 ++- src/routers/admin-router.ts | 2 + src/routers/admin-webhook-triggers-router.ts | 211 +++++++++++++++++ 14 files changed, 1001 insertions(+), 68 deletions(-) create mode 100644 src/app/admin/components/OrganizationAdmin/OrganizationAdminWebhooks.tsx create mode 100644 src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx create mode 100644 src/app/admin/organizations/[id]/webhooks/page.tsx create mode 100644 src/app/admin/users/[id]/webhooks/[triggerId]/page.tsx create mode 100644 src/app/admin/users/[id]/webhooks/page.tsx create mode 100644 src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx create mode 100644 src/app/admin/webhooks/AdminWebhookTriggersList.tsx create mode 100644 src/routers/admin-webhook-triggers-router.ts diff --git a/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx b/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx index 25257f3b3..6da82701f 100644 --- a/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx +++ b/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx @@ -9,6 +9,7 @@ import { toast } from 'sonner'; import { getWebhookRoutes } from '@/lib/webhook-routes'; import { Button } from '@/components/ui/button'; +import { CopyTextButton } from '@/components/admin/CopyEmailButton'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -41,6 +42,8 @@ import { type WebhookRequestsContentProps = { params: Promise<{ triggerId: string }>; organizationId?: string; + adminPathBase?: string; + adminUserId?: string; }; type RequestStatus = 'captured' | 'inprogress' | 'success' | 'failed'; @@ -98,7 +101,24 @@ function formatTimestamp(isoString: string): { absolute: string; relative: strin }; } -export function WebhookRequestsContent({ params, organizationId }: WebhookRequestsContentProps) { +function maskHeaderValue(value: string): string { + if (!value) return ''; + if (value.length <= 4) return '****'; + return `${'*'.repeat(Math.min(value.length, 8))}`; +} + +function maskHeaders(headers: Record): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, maskHeaderValue(value)]) + ); +} + +export function WebhookRequestsContent({ + params, + organizationId, + adminPathBase, + adminUserId, +}: WebhookRequestsContentProps) { const { triggerId } = use(params); const trpc = useTRPC(); @@ -108,6 +128,43 @@ export function WebhookRequestsContent({ params, organizationId }: WebhookReques // Build URLs based on context const routes = getWebhookRoutes(organizationId); + const isAdminView = !!adminPathBase; + const adminRoutes = adminPathBase + ? { + list: adminPathBase, + edit: `${adminPathBase}/${triggerId}`, + } + : null; + const listHref = adminRoutes?.list ?? routes.list; + const editHref = adminRoutes?.edit ?? routes.edit(triggerId); + + // Resolve admin scope — org or user, null when not in admin view. + // If admin view is active but neither ID is provided (should be impossible given + // caller constraints), adminScope stays null and the non-admin query paths run + // instead, which will surface a proper tRPC error in the existing error UI. + const adminScope = !isAdminView + ? null + : organizationId + ? ({ scope: 'organization', organizationId } as const) + : adminUserId + ? ({ scope: 'user', userId: adminUserId } as const) + : null; + + if (isAdminView && !adminScope) { + return ( +
+

Admin context missing

+

+ This page requires either an organization or user scope. +

+ {adminPathBase && ( + + )} +
+ ); + } // Fetch trigger data to get the inbound URL const { @@ -115,10 +172,12 @@ export function WebhookRequestsContent({ params, organizationId }: WebhookReques isLoading: isLoadingTrigger, error: triggerError, } = useQuery( - trpc.webhookTriggers.get.queryOptions({ - triggerId, - organizationId: organizationId ?? undefined, - }) + adminScope + ? trpc.admin.webhookTriggers.get.queryOptions({ ...adminScope, triggerId }) + : trpc.webhookTriggers.get.queryOptions({ + triggerId, + organizationId: organizationId ?? undefined, + }) ); // Fetch requests with auto-refresh every 10 seconds @@ -129,11 +188,17 @@ export function WebhookRequestsContent({ params, organizationId }: WebhookReques error: requestsError, refetch: refetchRequests, } = useQuery({ - ...trpc.webhookTriggers.listRequests.queryOptions({ - triggerId, - organizationId: organizationId ?? undefined, - limit: 50, - }), + ...(adminScope + ? trpc.admin.webhookTriggers.listRequests.queryOptions({ + ...adminScope, + triggerId, + limit: 50, + }) + : trpc.webhookTriggers.listRequests.queryOptions({ + triggerId, + organizationId: organizationId ?? undefined, + limit: 50, + })), refetchOnMount: 'always', // Always fetch fresh data on navigation refetchInterval: 10000, // Auto-refresh every 10 seconds }); @@ -216,7 +281,7 @@ export function WebhookRequestsContent({ params, organizationId }: WebhookReques
+ e.stopPropagation()}> + + +
+ ); + } + return organizationId ? ( // Org context: show share button (bot sessions aren't directly accessible)
{/* Body */}

Body

-
-                                
-                                  {(() => {
-                                    try {
-                                      // Try to parse and format JSON
-                                      const parsed = JSON.parse(request.body);
-                                      return JSON.stringify(parsed, null, 2);
-                                    } catch {
-                                      // Show raw text if not JSON
-                                      return request.body || '(empty)';
-                                    }
-                                  })()}
-                                
-                              
+ {isAdminView ? ( +

+ Payload body length: {request.body.length} bytes +

+ ) : ( +
+                                  
+                                    {(() => {
+                                      try {
+                                        // Try to parse and format JSON
+                                        const parsed = JSON.parse(request.body);
+                                        return JSON.stringify(parsed, null, 2);
+                                      } catch {
+                                        // Show raw text if not JSON
+                                        return request.body || '(empty)';
+                                      }
+                                    })()}
+                                  
+                                
+ )}
diff --git a/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx b/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx index eb390e50d..f5c0c2555 100644 --- a/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx +++ b/src/app/admin/components/OrganizationAdmin/OrganizationAdminDashboard.tsx @@ -10,6 +10,7 @@ import { OrganizationAdminCreditGrant } from './OrganizationAdminCreditGrant'; import { OrganizationAdminCreditNullify } from './OrganizationAdminCreditNullify'; import { OrganizationAdminCreatedBy } from './OrganizationAdminCreatedBy'; import { OrganizationWorkOSCard } from './OrganizationWorkOSCard'; +import { OrganizationAdminWebhooks } from './OrganizationAdminWebhooks'; import { OrganizationContextProvider } from '@/components/organizations/OrganizationContext'; import AdminPage from '@/app/admin/components/AdminPage'; import { @@ -70,6 +71,9 @@ export function OrganizationAdminDashboard({ organizationId }: { organizationId: +
+ +
diff --git a/src/app/admin/components/OrganizationAdmin/OrganizationAdminWebhooks.tsx b/src/app/admin/components/OrganizationAdmin/OrganizationAdminWebhooks.tsx new file mode 100644 index 000000000..fb0420404 --- /dev/null +++ b/src/app/admin/components/OrganizationAdmin/OrganizationAdminWebhooks.tsx @@ -0,0 +1,31 @@ +'use client'; + +import Link from 'next/link'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Webhook } from 'lucide-react'; + +type OrganizationAdminWebhooksProps = { + organizationId: string; +}; + +export function OrganizationAdminWebhooks({ organizationId }: OrganizationAdminWebhooksProps) { + return ( + + + Webhook Triggers + + +

+ Read-only view of webhook triggers and request history for this organization. +

+ +
+
+ ); +} diff --git a/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx b/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx index 2e872bcff..1e5944990 100644 --- a/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx +++ b/src/app/admin/components/UserAdmin/UserAdminAccountInfo.tsx @@ -11,6 +11,7 @@ import ResetToMagicLinkLoginButton from './ResetToMagicLinkLoginButton'; import CheckKiloPassButton from './CheckKiloPassButton'; import { Button } from '@/components/ui/button'; import Link from 'next/link'; +import { Webhook } from 'lucide-react'; type UserAdminAccountInfoProps = UserDetailProps; @@ -56,6 +57,12 @@ export function UserAdminAccountInfo(user: UserAdminAccountInfoProps) { View usage + abuse +
diff --git a/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx b/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx new file mode 100644 index 000000000..682275ec8 --- /dev/null +++ b/src/app/admin/organizations/[id]/webhooks/[triggerId]/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { Suspense } from 'react'; +import { AdminWebhookTriggerDetails } from '@/app/admin/webhooks/AdminWebhookTriggerDetails'; + +type AdminOrganizationWebhookDetailPageProps = { + params: Promise<{ id: string; triggerId: string }>; +}; + +export default function AdminOrganizationWebhookDetailPage({ + params, +}: AdminOrganizationWebhookDetailPageProps) { + return ( + Loading...
} + > + + + ); +} diff --git a/src/app/admin/organizations/[id]/webhooks/page.tsx b/src/app/admin/organizations/[id]/webhooks/page.tsx new file mode 100644 index 000000000..4019e5d69 --- /dev/null +++ b/src/app/admin/organizations/[id]/webhooks/page.tsx @@ -0,0 +1,41 @@ +import { getUserFromAuth } from '@/lib/user.server'; +import { redirect } from 'next/navigation'; +import { AdminWebhookTriggersList } from '@/app/admin/webhooks/AdminWebhookTriggersList'; +import { db } from '@/lib/drizzle'; +import { organizations } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export default async function AdminOrganizationWebhooksPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + redirect('/admin/unauthorized'); + } + + const { id } = await params; + const organizationId = decodeURIComponent(id); + + const organization = await db.query.organizations.findFirst({ + columns: { + id: true, + name: true, + }, + where: eq(organizations.id, organizationId), + }); + + if (!organization) { + redirect('/admin/organizations'); + } + + return ( + + ); +} diff --git a/src/app/admin/users/[id]/webhooks/[triggerId]/page.tsx b/src/app/admin/users/[id]/webhooks/[triggerId]/page.tsx new file mode 100644 index 000000000..1688a3051 --- /dev/null +++ b/src/app/admin/users/[id]/webhooks/[triggerId]/page.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { Suspense } from 'react'; +import { AdminWebhookTriggerDetails } from '@/app/admin/webhooks/AdminWebhookTriggerDetails'; + +type AdminUserWebhookDetailPageProps = { + params: Promise<{ id: string; triggerId: string }>; +}; + +export default function AdminUserWebhookDetailPage({ params }: AdminUserWebhookDetailPageProps) { + return ( + Loading...
} + > + + + ); +} diff --git a/src/app/admin/users/[id]/webhooks/page.tsx b/src/app/admin/users/[id]/webhooks/page.tsx new file mode 100644 index 000000000..4db52f41b --- /dev/null +++ b/src/app/admin/users/[id]/webhooks/page.tsx @@ -0,0 +1,46 @@ +import { getUserFromAuth } from '@/lib/user.server'; +import { redirect } from 'next/navigation'; +import { AdminWebhookTriggersList } from '@/app/admin/webhooks/AdminWebhookTriggersList'; +import { db } from '@/lib/drizzle'; +import { kilocode_users } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export default async function AdminUserWebhooksPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + redirect('/admin/unauthorized'); + } + + const { id } = await params; + const userId = decodeURIComponent(id); + + const user = await db.query.kilocode_users.findFirst({ + columns: { + id: true, + google_user_email: true, + google_user_name: true, + }, + where: eq(kilocode_users.id, userId), + }); + + if (!user) { + redirect('/admin/users'); + } + + const label = user.google_user_name + ? `${user.google_user_name} (${user.google_user_email})` + : user.google_user_email; + + return ( + + ); +} diff --git a/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx b/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx new file mode 100644 index 000000000..7466b4b8d --- /dev/null +++ b/src/app/admin/webhooks/AdminWebhookTriggerDetails.tsx @@ -0,0 +1,222 @@ +'use client'; + +import { use } from 'react'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ArrowLeft, Webhook, ExternalLink, ShieldCheck } from 'lucide-react'; +import { WebhookRequestsContent } from '@/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent'; + +type AdminWebhookTriggerDetailsProps = { + params: Promise<{ id: string; triggerId: string }>; + scope: 'user' | 'organization'; +}; + +function formatTimestamp(value: string) { + return new Date(value).toLocaleString(); +} + +export function AdminWebhookTriggerDetails({ params, scope }: AdminWebhookTriggerDetailsProps) { + const { id, triggerId } = use(params); + const trpc = useTRPC(); + + const ownerId = decodeURIComponent(id); + const isOrg = scope === 'organization'; + const listPath = isOrg + ? `/admin/organizations/${encodeURIComponent(ownerId)}/webhooks` + : `/admin/users/${encodeURIComponent(ownerId)}/webhooks`; + const parentPath = isOrg + ? `/admin/organizations/${encodeURIComponent(ownerId)}` + : `/admin/users/${encodeURIComponent(ownerId)}`; + + const triggerInput = isOrg + ? ({ scope: 'organization', organizationId: ownerId, triggerId } as const) + : ({ scope: 'user', userId: ownerId, triggerId } as const); + + const { data, isLoading, error, refetch } = useQuery( + trpc.admin.webhookTriggers.get.queryOptions(triggerInput) + ); + + const breadcrumbs = ( + <> + + {isOrg ? 'Organization' : 'User'} + + + + Webhook Triggers + + + + {triggerId} + + + ); + + if (isLoading) { + return ( + +
+
+ + +
+ + + + + + + + + + + +
+
+ ); + } + + if (error || !data) { + return ( + + + + Unable to load trigger + {error?.message ?? 'Trigger not found'} + + + + + + + + ); + } + + return ( + +
+
+ +
+ +

Trigger: {triggerId}

+
+

Read-only configuration and request history.

+
+ + + + + + Configuration + + Worker-backed configuration snapshot. + + +
+

Inbound URL

+
+ + {data.inboundUrl} + +
+
+
+

Status

+ + {data.isActive ? 'Active' : 'Inactive'} + +
+
+

GitHub Repo

+

{data.githubRepo}

+
+
+

Profile ID

+

{data.profileId ?? '—'}

+
+
+

Mode

+

{data.mode}

+
+
+

Model

+

{data.model}

+
+
+

Created

+

{formatTimestamp(data.createdAt)}

+
+
+

Webhook Auth

+

+ {data.webhookAuthConfigured + ? `Enabled (${data.webhookAuthHeader ?? 'header set'})` + : 'Disabled'} +

+
+
+

Prompt Template

+
+                {data.promptTemplate}
+              
+
+
+
+

Auto Commit

+

{data.autoCommit ? 'Enabled' : 'Disabled'}

+
+
+

Condense on Complete

+

{data.condenseOnComplete ? 'Enabled' : 'Disabled'}

+
+
+
+
+ +
+
+

Captured Requests

+ +
+ +
+
+
+ ); +} diff --git a/src/app/admin/webhooks/AdminWebhookTriggersList.tsx b/src/app/admin/webhooks/AdminWebhookTriggersList.tsx new file mode 100644 index 000000000..193217018 --- /dev/null +++ b/src/app/admin/webhooks/AdminWebhookTriggersList.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { useMemo, useState, useCallback } from 'react'; +import { useTRPC } from '@/lib/trpc/utils'; +import { useQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { + StatusFilter, + TriggersTable, + TriggersLoadingState, + TriggersErrorState, + type StatusFilterValue, +} from '@/components/webhook-triggers'; +import { ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +type AdminWebhookTriggersListProps = { + label: string; + backHref: string; + detailBasePath: string; +} & ( + | { userId: string; organizationId?: undefined } + | { organizationId: string; userId?: undefined } +); + +function resolveAdminScope( + props: Pick +) { + if (props.organizationId) { + return { scope: 'organization', organizationId: props.organizationId } as const; + } + // The union type guarantees exactly one of userId/organizationId is present, + // but TS can't narrow the else branch from a truthiness check on an optional + // field. The runtime check ensures we never send an empty string. + const userId = props.userId; + if (!userId) { + throw new Error('AdminWebhookTriggersList requires userId or organizationId'); + } + return { scope: 'user', userId } as const; +} + +export function AdminWebhookTriggersList(props: AdminWebhookTriggersListProps) { + const { label, backHref, detailBasePath } = props; + const trpc = useTRPC(); + const [statusFilter, setStatusFilter] = useState('all'); + const [copiedTriggerId, setCopiedTriggerId] = useState(null); + + const adminScope = resolveAdminScope(props); + + const { data, isLoading, isError, error, refetch } = useQuery( + trpc.admin.webhookTriggers.list.queryOptions(adminScope) + ); + + const triggers = useMemo(() => data ?? [], [data]); + + const filteredTriggers = useMemo(() => { + switch (statusFilter) { + case 'active': + return triggers.filter(trigger => trigger.isActive); + case 'inactive': + return triggers.filter(trigger => !trigger.isActive); + default: + return triggers; + } + }, [triggers, statusFilter]); + + const handleCopyUrl = useCallback( + async (triggerId: string) => { + const trigger = triggers.find(t => t.triggerId === triggerId); + if (!trigger) { + toast.error('Trigger not found'); + return; + } + + try { + await navigator.clipboard.writeText(trigger.inboundUrl); + setCopiedTriggerId(triggerId); + toast.success('Webhook URL copied to clipboard'); + setTimeout(() => setCopiedTriggerId(null), 2000); + } catch { + toast.error('Failed to copy URL'); + } + }, + [triggers] + ); + + const breadcrumbs = ( + <> + + Back + + + + Webhook Triggers + + + ); + + return ( + +
+
+
+ +

Webhook Triggers

+
+

Read-only view for {label}.

+
+ + + + {isLoading && } + + {isError && } + + {!isLoading && !isError && filteredTriggers.length === 0 && ( +
+

+ {triggers.length > 0 + ? `No ${statusFilter === 'active' ? 'active' : 'inactive'} triggers found.` + : 'No webhook triggers found.'} +

+ {triggers.length > 0 && ( + + )} +
+ )} + + {!isLoading && !isError && filteredTriggers.length > 0 && ( + `${detailBasePath}/${triggerId}`} + showDelete={false} + showEdit + editLabel="View Details" + /> + )} +
+
+ ); +} diff --git a/src/components/webhook-triggers/TriggersTable.tsx b/src/components/webhook-triggers/TriggersTable.tsx index f6ad7ec15..63635c5cc 100644 --- a/src/components/webhook-triggers/TriggersTable.tsx +++ b/src/components/webhook-triggers/TriggersTable.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo } from 'react'; +import { memo, type ComponentType } from 'react'; import Link from 'next/link'; import { formatDistanceToNow } from 'date-fns'; import { Button } from '@/components/ui/button'; @@ -21,14 +21,21 @@ export type TriggerItem = { githubRepo: string; isActive: boolean; createdAt: string; + webhookAuthConfigured?: boolean | null; + webhookAuthHeader?: string | null; }; type TriggersTableProps = { triggers: TriggerItem[]; onCopyUrl: (triggerId: string) => void; - onDelete: (triggerId: string, githubRepo: string) => void; + onDelete?: (triggerId: string, githubRepo: string) => void; copiedTriggerId: string | null; getEditUrl: (triggerId: string) => string; + showCopy?: boolean; + showEdit?: boolean; + showDelete?: boolean; + editLabel?: string; + editIcon?: ComponentType<{ className?: string }>; }; /** @@ -40,7 +47,15 @@ export const TriggersTable = memo(function TriggersTable({ onDelete, copiedTriggerId, getEditUrl, + showCopy = true, + showEdit = true, + showDelete = true, + editLabel = 'Edit Trigger', + editIcon, }: TriggersTableProps) { + const hasActions = showCopy || showEdit || showDelete; + const showAuthColumn = triggers.some(trigger => trigger.webhookAuthConfigured != null); + return (
@@ -49,8 +64,9 @@ export const TriggersTable = memo(function TriggersTable({ Trigger NameGitHub RepoStatus + {showAuthColumn && Webhook Auth} Created - Actions + {hasActions && Actions} @@ -62,6 +78,12 @@ export const TriggersTable = memo(function TriggersTable({ onDelete={onDelete} isCopied={copiedTriggerId === trigger.triggerId} editUrl={getEditUrl(trigger.triggerId)} + showAuthColumn={showAuthColumn} + showCopy={showCopy} + showEdit={showEdit} + showDelete={showDelete} + editLabel={editLabel} + editIcon={editIcon} /> ))} @@ -73,9 +95,15 @@ export const TriggersTable = memo(function TriggersTable({ type TriggerRowProps = { trigger: TriggerItem; onCopyUrl: (triggerId: string) => void; - onDelete: (triggerId: string, githubRepo: string) => void; + onDelete?: (triggerId: string, githubRepo: string) => void; isCopied: boolean; editUrl: string; + showAuthColumn: boolean; + showCopy: boolean; + showEdit: boolean; + showDelete: boolean; + editLabel: string; + editIcon?: ComponentType<{ className?: string }>; }; const TriggerRow = memo(function TriggerRow({ @@ -84,7 +112,17 @@ const TriggerRow = memo(function TriggerRow({ onDelete, isCopied, editUrl, + showAuthColumn, + showCopy, + showEdit, + showDelete, + editLabel, + editIcon, }: TriggerRowProps) { + const EditIcon = editIcon ?? Pencil; + const hasActions = showCopy || showEdit || showDelete; + const showAuthStatus = showAuthColumn && trigger.webhookAuthConfigured !== undefined; + return ( @@ -100,36 +138,63 @@ const TriggerRow = memo(function TriggerRow({ {trigger.isActive ? 'Active' : 'Inactive'} + {showAuthColumn && ( + + {showAuthStatus ? ( + trigger.webhookAuthConfigured === true ? ( + + Enabled{trigger.webhookAuthHeader ? ` (${trigger.webhookAuthHeader})` : ''} + + ) : trigger.webhookAuthConfigured === false ? ( + Disabled + ) : ( + + ) + ) : null} + + )} {formatDistanceToNow(new Date(trigger.createdAt), { addSuffix: true })} - -
- + {hasActions && ( + +
+ {showCopy && ( + + )} - + {showEdit && ( + + )} - -
-
+ {showDelete && onDelete && ( + + )} +
+
+ )}
); }); diff --git a/src/components/webhook-triggers/WebhookTriggersHeader.tsx b/src/components/webhook-triggers/WebhookTriggersHeader.tsx index 75919f088..e0e784525 100644 --- a/src/components/webhook-triggers/WebhookTriggersHeader.tsx +++ b/src/components/webhook-triggers/WebhookTriggersHeader.tsx @@ -9,6 +9,11 @@ import { Webhook, Plus } from 'lucide-react'; type WebhookTriggersHeaderProps = { createUrl: string; disabled?: boolean; + title?: string; + description?: string; + hideCreate?: boolean; + createLabel?: string; + badgeLabel?: string; }; /** @@ -18,25 +23,30 @@ type WebhookTriggersHeaderProps = { export const WebhookTriggersHeader = memo(function WebhookTriggersHeader({ createUrl, disabled, + title = 'Webhook Triggers', + description = 'Manage webhook triggers that automatically start cloud agent sessions.', + hideCreate = false, + createLabel = 'Create Trigger', + badgeLabel = 'new', }: WebhookTriggersHeaderProps) { return (
-

Webhook Triggers

- new +

{title}

+ {badgeLabel && {badgeLabel}}
- + {!hideCreate && ( + + )}
-

- Manage webhook triggers that automatically start cloud agent sessions. -

+

{description}

); }); diff --git a/src/routers/admin-router.ts b/src/routers/admin-router.ts index 11d622b08..6f23b696e 100644 --- a/src/routers/admin-router.ts +++ b/src/routers/admin-router.ts @@ -16,6 +16,7 @@ import { adminFeatureInterestRouter } from '@/routers/admin-feature-interest-rou import { adminCodeReviewsRouter } from '@/routers/admin-code-reviews-router'; import { adminAIAttributionRouter } from '@/routers/admin-ai-attribution-router'; import { ossSponsorshipRouter } from '@/routers/admin/oss-sponsorship-router'; +import { adminWebhookTriggersRouter } from '@/routers/admin-webhook-triggers-router'; import * as z from 'zod'; import { eq, and, ne, or, ilike, desc, asc, sql, isNull } from 'drizzle-orm'; import { findUsersByIds, findUserById } from '@/lib/user'; @@ -122,6 +123,7 @@ const GetUserInvoicesSchema = z.object({ }); export const adminRouter = createTRPCRouter({ + webhookTriggers: adminWebhookTriggersRouter, github: createTRPCRouter({ getKilocodeOpenPullRequestCounts: adminProcedure.query(async () => { return getKilocodeRepoOpenPullRequestCounts({ ttlMs: 2 * 60_000 }); diff --git a/src/routers/admin-webhook-triggers-router.ts b/src/routers/admin-webhook-triggers-router.ts new file mode 100644 index 000000000..905e075f6 --- /dev/null +++ b/src/routers/admin-webhook-triggers-router.ts @@ -0,0 +1,211 @@ +import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { TRPCError } from '@trpc/server'; +import { and, eq, isNull, inArray, desc } from 'drizzle-orm'; +import { z } from 'zod'; +import { db } from '@/lib/drizzle'; +import { cloud_agent_webhook_triggers, cliSessions } from '@/db/schema'; +import { triggerIdSchema } from '@/lib/webhook-trigger-validation'; +import { + getWorkerTrigger, + listWorkerRequests, + buildInboundUrl, + type EnrichedCapturedRequest, +} from '@/lib/webhook-agent/webhook-agent-client'; + +const AdminUserScope = z.object({ scope: z.literal('user'), userId: z.string() }); +const AdminOrgScope = z.object({ + scope: z.literal('organization'), + organizationId: z.string().uuid(), +}); +const AdminTriggerScopeSchema = z.discriminatedUnion('scope', [AdminUserScope, AdminOrgScope]); + +const AdminTriggerListInput = AdminTriggerScopeSchema; + +const AdminTriggerGetInput = AdminTriggerScopeSchema.and( + z.object({ + triggerId: triggerIdSchema, + }) +); + +const AdminTriggerRequestsInput = AdminTriggerGetInput.and( + z.object({ + limit: z.number().min(1).max(100).default(50), + }) +); + +function resolveScope(input: z.infer) { + if (input.scope === 'organization') { + return { scope: 'org' as const, id: input.organizationId }; + } + return { scope: 'user' as const, id: input.userId }; +} + +export const adminWebhookTriggersRouter = createTRPCRouter({ + list: adminProcedure.input(AdminTriggerListInput).query(async ({ input }) => { + const { scope, id } = resolveScope(input); + + const whereClause = + scope === 'org' + ? eq(cloud_agent_webhook_triggers.organization_id, id) + : and( + eq(cloud_agent_webhook_triggers.user_id, id), + isNull(cloud_agent_webhook_triggers.organization_id) + ); + + const triggers = await db + .select({ + id: cloud_agent_webhook_triggers.id, + triggerId: cloud_agent_webhook_triggers.trigger_id, + githubRepo: cloud_agent_webhook_triggers.github_repo, + isActive: cloud_agent_webhook_triggers.is_active, + createdAt: cloud_agent_webhook_triggers.created_at, + updatedAt: cloud_agent_webhook_triggers.updated_at, + }) + .from(cloud_agent_webhook_triggers) + .where(whereClause) + .orderBy(desc(cloud_agent_webhook_triggers.created_at)); + + return triggers.map(trigger => ({ + ...trigger, + inboundUrl: buildInboundUrl( + scope === 'user' ? id : undefined, + scope === 'org' ? id : undefined, + trigger.triggerId + ), + })); + }), + + get: adminProcedure.input(AdminTriggerGetInput).query(async ({ input }) => { + const { scope, id } = resolveScope(input); + + const whereClause = + scope === 'org' + ? and( + eq(cloud_agent_webhook_triggers.organization_id, id), + eq(cloud_agent_webhook_triggers.trigger_id, input.triggerId) + ) + : and( + eq(cloud_agent_webhook_triggers.user_id, id), + eq(cloud_agent_webhook_triggers.trigger_id, input.triggerId), + isNull(cloud_agent_webhook_triggers.organization_id) + ); + + const [trigger] = await db + .select({ + id: cloud_agent_webhook_triggers.id, + triggerId: cloud_agent_webhook_triggers.trigger_id, + }) + .from(cloud_agent_webhook_triggers) + .where(whereClause); + + if (!trigger) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Trigger not found' }); + } + + const workerResult = await getWorkerTrigger( + scope === 'user' ? id : undefined, + scope === 'org' ? id : undefined, + input.triggerId + ); + + if (workerResult.found === false) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Trigger not found' }); + } + + if (workerResult.found === 'error') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to fetch trigger configuration', + }); + } + + const inboundUrl = buildInboundUrl( + scope === 'user' ? id : undefined, + scope === 'org' ? id : undefined, + input.triggerId + ); + + return { + ...workerResult.config, + inboundUrl, + }; + }), + + listRequests: adminProcedure + .input(AdminTriggerRequestsInput) + .query(async ({ input }): Promise => { + const { scope, id } = resolveScope(input); + + const whereClause = + scope === 'org' + ? and( + eq(cloud_agent_webhook_triggers.organization_id, id), + eq(cloud_agent_webhook_triggers.trigger_id, input.triggerId) + ) + : and( + eq(cloud_agent_webhook_triggers.user_id, id), + eq(cloud_agent_webhook_triggers.trigger_id, input.triggerId), + isNull(cloud_agent_webhook_triggers.organization_id) + ); + + const [trigger] = await db + .select({ + id: cloud_agent_webhook_triggers.id, + triggerId: cloud_agent_webhook_triggers.trigger_id, + }) + .from(cloud_agent_webhook_triggers) + .where(whereClause); + + if (!trigger) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Trigger not found' }); + } + + const result = await listWorkerRequests( + scope === 'user' ? id : undefined, + scope === 'org' ? id : undefined, + input.triggerId, + input.limit + ); + + if (!result.success) { + if (result.isNotFound) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Trigger not found' }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to list requests', + }); + } + + const cloudAgentSessionIds = result.requests + .map(request => request.cloudAgentSessionId) + .filter((sessionId): sessionId is string => sessionId !== null); + + const sessionIdMap = + cloudAgentSessionIds.length > 0 + ? new Map( + ( + await db + .select({ + cloudAgentSessionId: cliSessions.cloud_agent_session_id, + sessionId: cliSessions.session_id, + }) + .from(cliSessions) + .where(inArray(cliSessions.cloud_agent_session_id, cloudAgentSessionIds)) + ) + .filter( + (session): session is { cloudAgentSessionId: string; sessionId: string } => + session.cloudAgentSessionId !== null + ) + .map(session => [session.cloudAgentSessionId, session.sessionId]) + ) + : new Map(); + + return result.requests.map(request => ({ + ...request, + kiloSessionId: request.cloudAgentSessionId + ? (sessionIdMap.get(request.cloudAgentSessionId) ?? null) + : null, + })); + }), +}); From 817ef2d97240ed918cb2a6aad3760a3e5875850f Mon Sep 17 00:00:00 2001 From: syn Date: Thu, 5 Feb 2026 18:10:54 -0600 Subject: [PATCH 2/2] pr feedback --- .../webhooks/[triggerId]/requests/WebhookRequestsContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx b/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx index 6da82701f..ca904ed4e 100644 --- a/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx +++ b/src/app/(app)/cloud/webhooks/[triggerId]/requests/WebhookRequestsContent.tsx @@ -574,7 +574,8 @@ export function WebhookRequestsContent({

Body

{isAdminView ? (

- Payload body length: {request.body.length} bytes + Payload body length:{' '} + {new TextEncoder().encode(request.body).byteLength} bytes

) : (