mirror of
https://github.com/hatchet-dev/hatchet.git
synced 2026-04-24 19:29:16 -05:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user