Feat: Webhook fixes / improvements (#2131)

* feat: webhook update

* feat: add headers to cel env

* fix: header casing

* feat: wire up edits

* fix: updates

* fix: finish wiring up updates

* fix: handle save on enter

* fix: lint

* feat: add slack and discord

* feat: initial slack setup

* fix: get slack working

* fix: rm discord for now

* fix: lint

* chore: gen

* fix: explicit save button

* feat: add link to CEL docs

* feat: add callout for reaching out to support

* feat: docs

* refactor: challenge

* fix: naming

* fix: return

* fix: resp codes

* fix: webhooks beta flag

* fix: rm discord

* fix: docs
This commit is contained in:
matt
2025-08-14 10:46:57 -05:00
committed by GitHub
parent 80ea9bdb4b
commit 36924936fa
24 changed files with 1290 additions and 406 deletions
+26 -7
View File
@@ -107,6 +107,7 @@ import {
V1TaskTimingList,
V1TriggerWorkflowRunRequest,
V1UpdateFilterRequest,
V1UpdateWebhookRequest,
V1Webhook,
V1WebhookList,
V1WebhookSourceName,
@@ -908,19 +909,37 @@ export class Api<
data?: any,
params: RequestParams = {},
) =>
this.request<
{
/** @example "OK" */
message?: string;
},
APIErrors
>({
this.request<Record<string, any>, APIErrors>({
path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`,
method: "POST",
body: data,
format: "json",
...params,
});
/**
* @description Update a webhook
*
* @tags Webhook
* @name V1WebhookUpdate
* @summary Update a webhook
* @request PATCH:/api/v1/stable/tenants/{tenant}/webhooks/{v1-webhook}
* @secure
*/
v1WebhookUpdate = (
tenant: string,
v1Webhook: string,
data: V1UpdateWebhookRequest,
params: RequestParams = {},
) =>
this.request<V1Webhook, APIErrors>({
path: `/api/v1/stable/tenants/${tenant}/webhooks/${v1Webhook}`,
method: "PATCH",
body: data,
secure: true,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description Evaluate a CEL expression against provided input data.
*
@@ -234,6 +234,7 @@ export enum V1WebhookSourceName {
GENERIC = "GENERIC",
GITHUB = "GITHUB",
STRIPE = "STRIPE",
SLACK = "SLACK",
}
export enum TenantUIVersion {
@@ -956,6 +957,11 @@ export type V1CreateWebhookRequest =
| V1CreateWebhookRequestAPIKey
| V1CreateWebhookRequestHMAC;
export interface V1UpdateWebhookRequest {
/** The CEL expression to use for the event key. This is used to create the event key from the webhook payload. */
eventKeyExpression: string;
}
export interface V1CELDebugRequest {
/** The CEL expression to evaluate */
expression: string;
@@ -169,42 +169,26 @@ const HMACAuth = ({ register, watch, setValue }: HMACAuthProps) => (
</div>
);
const StripeAuth = ({ register }: BaseAuthMethodProps) => (
// Stripe only requires a secret, as we know the header key and the encoding info (user doesn't need to provide them)
// See docs: https://docs.stripe.com/webhooks?verify=verify-manually#verify-manually
export const PreconfiguredHMACAuth = ({
register,
secretLabel = 'Signing Secret',
secretPlaceholder = 'super-secret',
}: BaseAuthMethodProps & {
secretLabel?: string;
secretPlaceholder?: string;
}) => (
// Intended to be used for Stripe, Slack, Github, etc.
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signingSecret" className="text-sm font-medium">
Webhook Signing Secret <span className="text-red-500">*</span>
{secretLabel} <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
data-1p-ignore
id="signingSecret"
type={'text'}
placeholder="whsec_..."
{...register('signingSecret')}
className="h-10 pr-10"
/>
</div>
</div>
</div>
);
const GithubAuth = ({ register }: BaseAuthMethodProps) => (
// Github only requires a secret, as we know the header key and the encoding info (user doesn't need to provide them)
// See docs: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries#validating-webhook-deliveries
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="signingSecret" className="text-sm font-medium">
Secret <span className="text-red-500">*</span>
</Label>
<div className="relative">
<Input
data-1p-ignore
id="signingSecret"
type={'text'}
placeholder="super-secret"
placeholder={secretPlaceholder}
{...register('signingSecret')}
className="h-10 pr-10"
/>
@@ -240,9 +224,16 @@ export const AuthSetup = ({
throw new Error(`Unhandled auth method: ${exhaustiveCheck}`);
}
case V1WebhookSourceName.GITHUB:
return <GithubAuth register={register} />;
return <PreconfiguredHMACAuth register={register} />;
case V1WebhookSourceName.STRIPE:
return <StripeAuth register={register} />;
return (
<PreconfiguredHMACAuth
register={register}
secretPlaceholder="whsec_..."
/>
);
case V1WebhookSourceName.SLACK:
return <PreconfiguredHMACAuth register={register} />;
default:
// eslint-disable-next-line no-case-declarations
const exhaustiveCheck: never = sourceName;
@@ -1,7 +1,7 @@
import { V1WebhookSourceName } from '@/lib/api';
import { GitHubLogoIcon } from '@radix-ui/react-icons';
import { Webhook } from 'lucide-react';
import { FaStripeS } from 'react-icons/fa';
import { FaSlack, FaStripeS } from 'react-icons/fa';
export const SourceName = ({
sourceName,
@@ -30,6 +30,13 @@ export const SourceName = ({
Stripe
</span>
);
case V1WebhookSourceName.SLACK:
return (
<span className="flex flex-row gap-x-2 items-center">
<FaSlack className="size-4" />
Slack
</span>
);
default:
// eslint-disable-next-line no-case-declarations
@@ -2,15 +2,16 @@ import { ColumnDef, Row } from '@tanstack/react-table';
import { V1Webhook } from '@/lib/api';
import { DataTableColumnHeader } from '@/components/v1/molecules/data-table/data-table-column-header';
import { DotsVerticalIcon } from '@radix-ui/react-icons';
import { Check, Copy, Loader, Trash2 } from 'lucide-react';
import { Check, Copy, Loader, Save, Trash2, X } from 'lucide-react';
import { Button } from '@/components/v1/ui/button';
import { Input } from '@/components/v1/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/v1/ui/dropdown-menu';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { useWebhooks } from '../hooks/use-webhooks';
import { SourceName } from './source-name';
import { AuthMethod } from './auth-method';
@@ -44,11 +45,7 @@ export const columns = (): ColumnDef<V1Webhook>[] => {
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Expression" />
),
cell: ({ row }) => (
<code className="bg-muted relative rounded px-2 py-1 font-mono text-xs h-full">
{row.original.eventKeyExpression}
</code>
),
cell: ({ row }) => <EditableExpressionCell row={row} />,
enableSorting: false,
enableHiding: true,
},
@@ -137,3 +134,57 @@ const WebhookActionsCell = ({ row }: { row: Row<V1Webhook> }) => {
</DropdownMenu>
);
};
const EditableExpressionCell = ({ row }: { row: Row<V1Webhook> }) => {
const { mutations } = useWebhooks();
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(row.original.eventKeyExpression || '');
const handleSave = useCallback(() => {
if (value !== row.original.eventKeyExpression && value.trim()) {
mutations.updateWebhook({
webhookName: row.original.name,
webhookData: { eventKeyExpression: value.trim() },
});
}
setIsEditing(false);
}, [value, row.original.eventKeyExpression, row.original.name, mutations]);
const handleCancel = useCallback(() => {
setValue(row.original.eventKeyExpression || '');
setIsEditing(false);
}, [row.original.eventKeyExpression, setIsEditing, setValue]);
return (
<div className="flex flex-row items-center gap-x-2">
<Input
value={isEditing ? value : row.original.eventKeyExpression || ''}
onChange={isEditing ? (e) => setValue(e.target.value) : undefined}
onClick={!isEditing ? () => setIsEditing(true) : undefined}
className={`bg-muted rounded px-2 py-3 font-mono text-xs w-full h-6 ${
isEditing
? 'border-input focus:border-ring focus:ring-1 focus:ring-ring cursor-text'
: 'border-transparent cursor-text hover:bg-muted/80'
}`}
readOnly={!isEditing}
autoFocus={isEditing}
/>
<Button
variant="ghost"
size="icon"
onClick={handleSave}
disabled={!isEditing}
>
<Save className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
disabled={value === row.original.eventKeyExpression || !isEditing}
>
<X className="size-4" />
</Button>
</div>
);
};
@@ -3,6 +3,7 @@ import { useCurrentTenantId } from '@/hooks/use-tenant';
import api, {
queries,
V1CreateWebhookRequest,
V1UpdateWebhookRequest,
V1WebhookAuthType,
V1WebhookHMACAlgorithm,
V1WebhookHMACEncoding,
@@ -44,6 +45,22 @@ export const useWebhooks = (onDeleteSuccess?: () => void) => {
},
});
const { mutate: updateWebhook, isPending: isUpdatePending } = useMutation({
mutationFn: async ({
webhookName,
webhookData,
}: {
webhookName: string;
webhookData: V1UpdateWebhookRequest;
}) => api.v1WebhookUpdate(tenantId, webhookName, webhookData),
onSuccess: async () => {
const queryKey = queries.v1Webhooks.list(tenantId).queryKey;
await queryClient.invalidateQueries({
queryKey,
});
},
});
const createWebhookURL = (name: string) => {
return `${window.location.protocol}//${window.location.hostname}/api/v1/stable/tenants/${tenantId}/webhooks/${name}`;
};
@@ -58,6 +75,8 @@ export const useWebhooks = (onDeleteSuccess?: () => void) => {
isDeletePending,
createWebhook,
isCreatePending,
updateWebhook,
isUpdatePending,
},
};
};
@@ -33,11 +33,12 @@ import {
V1WebhookHMACAlgorithm,
V1WebhookHMACEncoding,
} from '@/lib/api';
import { Webhook, Copy, Check, AlertTriangle } from 'lucide-react';
import { Webhook, Copy, Check, AlertTriangle, Lightbulb } from 'lucide-react';
import { Spinner } from '@/components/v1/ui/loading';
import { SourceName } from './components/source-name';
import { AuthMethod } from './components/auth-method';
import { AuthSetup } from './components/auth-setup';
import { Link } from 'react-router-dom';
const WebhookEmptyState = () => {
return (
@@ -158,7 +159,7 @@ const buildWebhookPayload = (data: WebhookFormData): V1CreateWebhookRequest => {
};
case V1WebhookSourceName.STRIPE:
if (!data.signingSecret) {
throw new Error('Signing secret is required for GitHub webhooks');
throw new Error('Signing secret is required for Stripe webhooks');
}
return {
@@ -177,6 +178,25 @@ const buildWebhookPayload = (data: WebhookFormData): V1CreateWebhookRequest => {
signingSecret: data.signingSecret,
},
};
case V1WebhookSourceName.SLACK:
if (!data.signingSecret) {
throw new Error('signing secret is required for Slack webhooks');
}
return {
sourceName: data.sourceName,
name: data.name,
eventKeyExpression: data.eventKeyExpression,
authType: V1WebhookAuthType.HMAC,
auth: {
// Slack sends the expected signature and timestamp as headers
// https://api.slack.com/apis/events-api#receiving-events
algorithm: V1WebhookHMACAlgorithm.SHA256,
encoding: V1WebhookHMACEncoding.HEX,
signatureHeaderName: 'X-Slack-Signature',
signingSecret: data.signingSecret,
},
};
default:
// eslint-disable-next-line no-case-declarations
const exhaustiveCheck: never = data.sourceName;
@@ -190,6 +210,7 @@ const createSourceInlineDescription = (sourceName: V1WebhookSourceName) => {
return '(receive incoming webhook requests from any service)';
case V1WebhookSourceName.GITHUB:
case V1WebhookSourceName.STRIPE:
case V1WebhookSourceName.SLACK:
return '';
default:
// eslint-disable-next-line no-case-declarations
@@ -212,6 +233,7 @@ const SourceCaption = ({ sourceName }: { sourceName: V1WebhookSourceName }) => {
);
case V1WebhookSourceName.GENERIC:
case V1WebhookSourceName.STRIPE:
case V1WebhookSourceName.SLACK:
return '';
default:
// eslint-disable-next-line no-case-declarations
@@ -292,18 +314,23 @@ const CreateWebhookModal = () => {
</DialogTrigger>
<DialogContent className="max-w-[90%] md:max-w-[80%] lg:max-w-[60%] xl:max-w-[50%] max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 bg-blue-100 rounded-full">
<Webhook className="h-4 w-4 text-indigo-700" />
<DialogTitle className="flex flex-col items-start gap-y-4">
<div className="flex flex-row items-center gap-x-3">
<div className="flex items-center justify-center w-8 h-8 bg-blue-100 rounded-full">
<Webhook className="h-4 w-4 text-indigo-700" />
</div>
Create a webhook
</div>
Create a webhook
<span className="text-sm text-muted-foreground">
Webhooks are a beta feature
</span>
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">
Webhook ID <span className="text-red-500">*</span>
Webhook Name <span className="text-red-500">*</span>
</Label>
<Input
data-1p-ignore
@@ -365,6 +392,17 @@ const CreateWebhookModal = () => {
</div>
</SelectItem>
))}
<SelectItem
disabled
key="empty"
value="reach-out"
className="text-sm data-[disabled]:text-white data-[disabled]:opacity-100"
>
<div className="flex flex-row items-center gap-x-2">
<Lightbulb className="size-4 text-yellow-500" />
<span>Want a new source added? Reach out to support</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<SourceCaption sourceName={sourceName} />
@@ -385,10 +423,25 @@ const CreateWebhookModal = () => {
{errors.eventKeyExpression.message}
</p>
)}
<p className="text-xs text-muted-foreground">
CEL expression to extract the event key from the webhook payload.
Use `input` to refer to the payload.
</p>
<div className="text-xs text-muted-foreground pl-1">
<p>
CEL expression to extract the event key from the webhook
payload. See{' '}
<Link
to="https://cel.dev/"
className="text-blue-600"
target="_blank"
rel="noopener noreferrer"
>
the docs
</Link>{' '}
for details.
</p>
<ul className="list-disc pl-4">
<li>`input` refers to the payload</li>
<li>`headers` refers to the headers</li>
</ul>
</div>
</div>
<div className="space-y-4">