From df55c5c3276994a714a79cd7dd5a0d7cde702345 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:56:54 +1100 Subject: [PATCH] feat(studio): platform webhooks scaffolding (#43276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Impending feature addition. Resolves DEPR-340. ## What is the current behavior? We don’t have any platform webhook support. ## What is the new behavior? This puts the scaffolding for platform webhooks **behind a feature flag**. The content is currently UI-only (with mock data and a [temporary tracking file](https://github.com/supabase/supabase/blob/8adadc61f57c70b686a0f5f05e9f7f05837e0466/apps/studio/components/interfaces/Platform/Webhooks/DEPR-340-backend-integration-tracker.md)). Merging this in lets us work incrementally from here on. --- .../App/CommandMenu/CreateCommands.utils.tsx | 2 +- .../FeaturePreview/FeaturePreviewContext.tsx | 6 + .../FeaturePreview/FeaturePreviewModal.tsx | 2 + .../PlatformWebhooksPreview.tsx | 26 + .../App/FeaturePreview/useFeaturePreviews.ts | 10 + .../Webhooks/PlatformWebhooks.mock.ts | 124 ++++ .../Webhooks/PlatformWebhooks.store.ts | 267 ++++++++ .../Webhooks/PlatformWebhooks.types.ts | 50 ++ .../PlatformWebhooksDeliveryDetailsSheet.tsx | 187 ++++++ .../PlatformWebhooksEndpointDetails.tsx | 175 ++++++ .../Webhooks/PlatformWebhooksEndpointList.tsx | 232 +++++++ .../PlatformWebhooksEndpointSheet.tsx | 581 ++++++++++++++++++ .../Webhooks/PlatformWebhooksHeader.tsx | 91 +++ .../Webhooks/PlatformWebhooksPage.tsx | 483 +++++++++++++++ .../PlatformWebhooksPage.utils.test.ts | 87 +++ .../Webhooks/PlatformWebhooksPage.utils.ts | 47 ++ .../Webhooks/PlatformWebhooksView.utils.ts | 31 + .../interfaces/Platform/Webhooks/index.ts | 11 + apps/studio/components/interfaces/Sidebar.tsx | 6 +- .../DatabaseLayout/Database.Commands.tsx | 7 - .../DatabaseLayout/DatabaseMenu.utils.tsx | 2 +- .../NavigationBar/NavigationBar.utils.tsx | 10 +- .../OrganizationSettingsLayout.test.ts | 32 + .../OrganizationSettingsLayout.tsx | 98 ++- .../ProjectSettings.Commands.tsx | 27 +- .../ProjectSettingsLayout/SettingsLayout.tsx | 3 + .../SettingsMenu.utils.test.tsx | 43 ++ .../SettingsMenu.utils.tsx | 13 + .../org/[slug]/webhooks/[endpointId].tsx | 24 + .../pages/org/[slug]/webhooks/index.tsx | 19 + .../[ref]/settings/webhooks/[endpointId].tsx | 21 + .../project/[ref]/settings/webhooks/index.tsx | 16 + packages/common/constants/local-storage.ts | 2 + 33 files changed, 2713 insertions(+), 22 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.test.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.utils.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksView.utils.ts create mode 100644 apps/studio/components/interfaces/Platform/Webhooks/index.ts create mode 100644 apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx create mode 100644 apps/studio/pages/org/[slug]/webhooks/[endpointId].tsx create mode 100644 apps/studio/pages/org/[slug]/webhooks/index.tsx create mode 100644 apps/studio/pages/project/[ref]/settings/webhooks/[endpointId].tsx create mode 100644 apps/studio/pages/project/[ref]/settings/webhooks/index.tsx diff --git a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx index 4c7adb5bdea..c3493239a28 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CreateCommands.utils.tsx @@ -74,7 +74,7 @@ export function getIntegrationCommandName(integration: IntegrationDefinition): s case 'cron': return 'Create Cron Job' case 'webhooks': - return 'Create Webhook' + return 'Create Database Webhook' case 'queues': return 'Create Queue' default: diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 0ec643b3029..7fbd506af9f 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -112,6 +112,12 @@ export const useIsQueueOperationsEnabled = () => { return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS] } +export const useIsPlatformWebhooksEnabled = () => { + const { flags } = useFeaturePreviewContext() + const platformWebhooksEnabled = useFlag('platformWebhooks') + return platformWebhooksEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS] +} + export const useIsTableFilterBarEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 2c4c5f7a2ba..faaeae557e0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -24,6 +24,7 @@ import { APISidePanelPreview } from './APISidePanelPreview' import { Branching2Preview } from './Branching2Preview' import { CLSPreview } from './CLSPreview' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' +import { PlatformWebhooksPreview } from './PlatformWebhooksPreview' import { PgDeltaDiffPreview } from './PgDeltaDiffPreview' import { QueueOperationsPreview } from './QueueOperationsPreview' import { TableFilterBarPreview } from './TableFilterBarPreview' @@ -42,6 +43,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: , } export const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx new file mode 100644 index 00000000000..a0824e80246 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/PlatformWebhooksPreview.tsx @@ -0,0 +1,26 @@ +import { useParams } from 'common' + +import { InlineLink } from '@/components/ui/InlineLink' + +export const PlatformWebhooksPreview = () => { + const { slug = '_', ref = '_' } = useParams() + + return ( +
+

+ Configure webhook endpoints and review deliveries from both project and organization + settings pages. +

+
    +
  • + Project scope:{' '} + Project Webhooks +
  • +
  • + Organization scope:{' '} + Organization Webhooks +
  • +
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index c0c957bce91..df3f9535d6c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -17,6 +17,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') const tableEditorNewFilterBar = useFlag('tableEditorNewFilterBar') const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') + const platformWebhooksEnabled = useFlag('platformWebhooks') return [ { @@ -56,6 +57,15 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isDefaultOptIn: true, enabled: pgDeltaDiffEnabled, }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, + name: 'Platform webhooks', + discussionsUrl: undefined, + isNew: true, + isPlatformOnly: true, + isDefaultOptIn: false, + enabled: platformWebhooksEnabled, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts new file mode 100644 index 00000000000..1a10d55d272 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.mock.ts @@ -0,0 +1,124 @@ +import type { PlatformWebhooksMockSeed, WebhookScope } from './PlatformWebhooks.types' + +export const PLATFORM_WEBHOOKS_MOCK_DATA: Record = { + organization: { + eventTypes: [ + 'organization.member_invited', + 'organization.member_removed', + 'organization.updated', + 'project.created', + 'project.updated', + 'project.deleted', + 'project.paused', + 'project.resumed', + ], + endpoints: [ + { + id: 'org-endpoint-1', + name: 'Lovable production', + url: 'https://api.lovable.dev/webhooks/supabase', + description: 'Primary organization webhook endpoint', + enabled: true, + eventTypes: ['project.created', 'project.updated', 'project.deleted'], + customHeaders: [ + { id: 'org-header-1', key: 'X-Lovable-Version', value: 'v2' }, + { id: 'org-header-2', key: 'X-Environment', value: 'production' }, + ], + createdBy: 'user@supabase.io', + createdAt: '2026-02-15T21:30:00.000Z', + }, + { + id: 'org-endpoint-2', + name: 'Slack notifications', + url: 'https://hooks.slack.com/services/T000/B000/ORG', + description: 'Operational alerts for organization-level events', + enabled: false, + eventTypes: ['organization.updated'], + customHeaders: [], + createdBy: 'ops@supabase.io', + createdAt: '2026-02-12T10:14:00.000Z', + }, + ], + deliveries: [ + { + id: 'org-delivery-1', + endpointId: 'org-endpoint-1', + eventType: 'project.created', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T08:04:00.000Z', + }, + { + id: 'org-delivery-2', + endpointId: 'org-endpoint-1', + eventType: 'project.updated', + status: 'failure', + responseCode: 500, + attemptAt: '2026-02-27T07:56:00.000Z', + }, + { + id: 'org-delivery-3', + endpointId: 'org-endpoint-1', + eventType: 'project.deleted', + status: 'pending', + attemptAt: '2026-02-27T07:45:00.000Z', + }, + { + id: 'org-delivery-4', + endpointId: 'org-endpoint-2', + eventType: 'organization.updated', + status: 'skipped', + attemptAt: '2026-02-26T14:12:00.000Z', + }, + ], + }, + project: { + eventTypes: [ + 'project.updated', + 'project.paused', + 'project.resumed', + 'project.branch_created', + 'project.branch_deleted', + 'project.resource_exhausted', + ], + endpoints: [ + { + id: 'project-endpoint-1', + name: 'Project analytics', + url: 'https://analytics.example.com/hooks/supabase-project', + description: 'Project-level status updates for analytics pipeline', + enabled: true, + eventTypes: ['project.updated', 'project.resource_exhausted'], + customHeaders: [{ id: 'project-header-1', key: 'X-Project-Type', value: 'platform' }], + createdBy: 'owner@project.dev', + createdAt: '2026-02-18T11:23:00.000Z', + }, + ], + deliveries: [ + { + id: 'project-delivery-1', + endpointId: 'project-endpoint-1', + eventType: 'project.updated', + status: 'success', + responseCode: 200, + attemptAt: '2026-02-27T09:01:00.000Z', + }, + { + id: 'project-delivery-2', + endpointId: 'project-endpoint-1', + eventType: 'project.resource_exhausted', + status: 'failure', + responseCode: 429, + attemptAt: '2026-02-27T08:47:00.000Z', + }, + { + id: 'project-delivery-3', + endpointId: 'project-endpoint-1', + eventType: 'project.paused', + status: 'success', + responseCode: 202, + attemptAt: '2026-02-26T21:10:00.000Z', + }, + ], + }, +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts new file mode 100644 index 00000000000..a73e6461c46 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.store.ts @@ -0,0 +1,267 @@ +import { useEffect, useState } from 'react' + +import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock' +import type { + PlatformWebhooksState, + UpsertWebhookEndpointInput, + WebhookDelivery, + WebhookEndpoint, + WebhookScope, +} from './PlatformWebhooks.types' + +interface CreateEndpointOptions { + now?: string + endpointId?: string + createdBy?: string + signingSecret?: string +} + +interface UpdateEndpointOptions { + headerIdFactory?: () => string +} + +const secureRandomHex = (length: number) => { + if (length <= 0) return '' + + const cryptoApi = globalThis.crypto + if (!cryptoApi?.getRandomValues) { + throw new Error('Web Crypto API is not available') + } + + const byteCount = Math.ceil(length / 2) + const bytes = new Uint8Array(byteCount) + cryptoApi.getRandomValues(bytes) + + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')) + .join('') + .slice(0, length) +} + +const randomId = (prefix: string) => `${prefix}-${secureRandomHex(8)}` + +const generateSigningSecret = () => `whsec_${secureRandomHex(16)}` + +const deepClone = (value: T): T => JSON.parse(JSON.stringify(value)) + +const toHeaders = ( + headers: UpsertWebhookEndpointInput['customHeaders'], + options?: UpdateEndpointOptions +) => { + const headerIdFactory = options?.headerIdFactory ?? (() => randomId('header')) + return headers + .map((header) => ({ + id: headerIdFactory(), + key: header.key.trim(), + value: header.value.trim(), + })) + .filter((header) => header.key.length > 0 && header.value.length > 0) +} + +const normalizeSearch = (value: string) => value.trim().toLowerCase() + +export const createInitialPlatformWebhooksState = (scope: WebhookScope): PlatformWebhooksState => { + const seed = PLATFORM_WEBHOOKS_MOCK_DATA[scope] + return { + endpoints: deepClone(seed.endpoints), + deliveries: deepClone(seed.deliveries), + } +} + +const persistedMockStateByScope: Partial> = {} + +const getPersistedMockState = (scope: WebhookScope) => { + const persistedState = persistedMockStateByScope[scope] + if (persistedState) return persistedState + + const initialState = createInitialPlatformWebhooksState(scope) + persistedMockStateByScope[scope] = initialState + return initialState +} + +// Test-only helper to avoid cross-test state leakage in hook tests. +export const resetPlatformWebhooksMockStateForTests = (scope?: WebhookScope) => { + if (scope) { + delete persistedMockStateByScope[scope] + return + } + + for (const key of Object.keys(persistedMockStateByScope) as WebhookScope[]) { + delete persistedMockStateByScope[key] + } +} + +export const createWebhookEndpoint = ( + state: PlatformWebhooksState, + input: UpsertWebhookEndpointInput, + options?: CreateEndpointOptions +): { state: PlatformWebhooksState; endpoint: WebhookEndpoint; signingSecret: string } => { + const endpointId = options?.endpointId ?? randomId('endpoint') + const internalName = input.name.trim().length > 0 ? input.name.trim() : endpointId + const signingSecret = options?.signingSecret ?? generateSigningSecret() + const endpoint: WebhookEndpoint = { + id: endpointId, + name: internalName, + url: input.url.trim(), + description: input.description.trim(), + enabled: input.enabled, + eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'], + customHeaders: toHeaders(input.customHeaders), + createdBy: options?.createdBy ?? 'mock-user@supabase.io', + createdAt: options?.now ?? new Date().toISOString(), + } + + return { + endpoint, + signingSecret, + state: { + ...state, + endpoints: [endpoint, ...state.endpoints], + }, + } +} + +export const updateWebhookEndpoint = ( + state: PlatformWebhooksState, + endpointId: string, + input: UpsertWebhookEndpointInput, + options?: UpdateEndpointOptions +) => { + return { + ...state, + endpoints: state.endpoints.map((endpoint) => + endpoint.id === endpointId + ? { + ...endpoint, + name: input.name.trim().length > 0 ? input.name.trim() : endpoint.name, + url: input.url.trim(), + description: input.description.trim(), + enabled: input.enabled, + eventTypes: input.eventTypes.length > 0 ? input.eventTypes : ['*'], + customHeaders: toHeaders(input.customHeaders, options), + } + : endpoint + ), + } +} + +export const deleteWebhookEndpoint = (state: PlatformWebhooksState, endpointId: string) => { + return { + endpoints: state.endpoints.filter((endpoint) => endpoint.id !== endpointId), + deliveries: state.deliveries.filter((delivery) => delivery.endpointId !== endpointId), + } +} + +export const toggleWebhookEndpoint = ( + state: PlatformWebhooksState, + endpointId: string, + enabled?: boolean +) => { + return { + ...state, + endpoints: state.endpoints.map((endpoint) => + endpoint.id === endpointId + ? { ...endpoint, enabled: enabled === undefined ? !endpoint.enabled : enabled } + : endpoint + ), + } +} + +export const regenerateWebhookEndpointSecret = ( + state: PlatformWebhooksState, + endpointId: string, + secret?: string +) => { + const endpointExists = state.endpoints.some((endpoint) => endpoint.id === endpointId) + if (!endpointExists) return { state, signingSecret: null } + + return { + state: { ...state }, + signingSecret: secret ?? generateSigningSecret(), + } +} + +export const filterWebhookEndpoints = (endpoints: WebhookEndpoint[], search: string) => { + const normalizedSearch = normalizeSearch(search) + if (normalizedSearch.length === 0) return endpoints + + return endpoints.filter((endpoint) => { + const haystack = `${endpoint.name} ${endpoint.url} ${endpoint.description}`.toLowerCase() + return haystack.includes(normalizedSearch) + }) +} + +export const filterWebhookDeliveries = ( + deliveries: WebhookDelivery[], + endpointId: string, + search: string +) => { + const normalizedSearch = normalizeSearch(search) + + return deliveries + .filter((delivery) => delivery.endpointId === endpointId) + .filter((delivery) => { + if (normalizedSearch.length === 0) return true + const haystack = + `${delivery.eventType} ${delivery.status} ${delivery.responseCode ?? ''}`.toLowerCase() + return haystack.includes(normalizedSearch) + }) +} + +export const usePlatformWebhooksMockStore = (scope: WebhookScope) => { + const [state, setState] = useState(() => + deepClone(getPersistedMockState(scope)) + ) + + useEffect(() => { + setState(deepClone(getPersistedMockState(scope))) + }, [scope]) + + const applyStateUpdate = ( + updater: (previous: PlatformWebhooksState) => PlatformWebhooksState + ) => { + setState((previous) => { + const next = updater(previous) + persistedMockStateByScope[scope] = next + return next + }) + } + + return { + ...state, + createEndpoint: (input: UpsertWebhookEndpointInput) => { + const endpointId = randomId('endpoint') + const now = new Date().toISOString() + const signingSecret = generateSigningSecret() + const createdBy = 'mock-user@supabase.io' + let createdSecret = signingSecret + applyStateUpdate((prev) => { + const next = createWebhookEndpoint(prev, input, { + endpointId, + now, + signingSecret, + createdBy, + }) + createdSecret = next.signingSecret + return next.state + }) + return { endpointId, signingSecret: createdSecret } + }, + updateEndpoint: (endpointId: string, input: UpsertWebhookEndpointInput) => { + applyStateUpdate((prev) => updateWebhookEndpoint(prev, endpointId, input)) + }, + deleteEndpoint: (endpointId: string) => { + applyStateUpdate((prev) => deleteWebhookEndpoint(prev, endpointId)) + }, + toggleEndpoint: (endpointId: string, enabled?: boolean) => { + applyStateUpdate((prev) => toggleWebhookEndpoint(prev, endpointId, enabled)) + }, + regenerateSecret: (endpointId: string) => { + const currentState = persistedMockStateByScope[scope] ?? state + const next = regenerateWebhookEndpointSecret(currentState, endpointId) + if (!next.signingSecret) return null + + applyStateUpdate(() => next.state) + return next.signingSecret + }, + } +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts new file mode 100644 index 00000000000..b98c56268db --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooks.types.ts @@ -0,0 +1,50 @@ +export type WebhookScope = 'organization' | 'project' + +export type WebhookDeliveryStatus = 'pending' | 'success' | 'failure' | 'skipped' + +export interface WebhookHeader { + id: string + key: string + value: string +} + +export interface WebhookEndpoint { + id: string + name: string + url: string + description: string + enabled: boolean + eventTypes: string[] + customHeaders: WebhookHeader[] + createdBy: string + createdAt: string +} + +export interface WebhookDelivery { + id: string + endpointId: string + eventType: string + status: WebhookDeliveryStatus + responseCode?: number + attemptAt: string +} + +export interface PlatformWebhooksMockSeed { + eventTypes: string[] + endpoints: WebhookEndpoint[] + deliveries: WebhookDelivery[] +} + +export interface PlatformWebhooksState { + endpoints: WebhookEndpoint[] + deliveries: WebhookDelivery[] +} + +export interface UpsertWebhookEndpointInput { + name: string + url: string + description: string + enabled: boolean + eventTypes: string[] + customHeaders: Array> +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx new file mode 100644 index 00000000000..ab0b57a8c91 --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksDeliveryDetailsSheet.tsx @@ -0,0 +1,187 @@ +import { Copy } from 'lucide-react' + +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + AlertDialog, + Badge, + Button, + Card, + CardContent, + Separator, + Sheet, + SheetContent, + SheetHeader, + SheetSection, + SheetTitle, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import type { WebhookDelivery } from './PlatformWebhooks.types' +import { formatDeliveryStatus, statusBadgeVariant } from './PlatformWebhooksView.utils' + +interface PlatformWebhooksDeliveryDetailsSheetProps { + deliveryAttempt: number | null + deliveryDetailsTab: 'event' | 'response' + deliveryEventPayload: string + deliveryResponsePayload: string + open: boolean + selectedDelivery: WebhookDelivery | null + onCopy: (value: string, label: string) => void + onOpenChange: (open: boolean) => void + onTabChange: (tab: 'event' | 'response') => void +} + +export const PlatformWebhooksDeliveryDetailsSheet = ({ + deliveryAttempt, + deliveryDetailsTab, + deliveryEventPayload, + deliveryResponsePayload, + open, + selectedDelivery, + onCopy, + onOpenChange, + onTabChange, +}: PlatformWebhooksDeliveryDetailsSheetProps) => { + return ( + + + +
+ Delivery details + {selectedDelivery && ( + + {formatDeliveryStatus(selectedDelivery.status)} + + )} +
+
+ + + {selectedDelivery && ( + +
+ + +
+

Delivery ID

+ {selectedDelivery.id} +
+
+

Attempt

+

+ {deliveryAttempt ? `#${deliveryAttempt}` : '-'} +

+
+
+

Timestamp

+ +
+
+

Response code

+ {selectedDelivery.responseCode ? ( + + ) : ( + – + )} +
+
+
+ + onTabChange(value as 'event' | 'response')} + > + + + Event + + + Response + + + + +
+

Event type

+ {selectedDelivery.eventType} +
+
+

Event ID

+ {selectedDelivery.id} +
+
+

Event timestamp

+ +
+
+
+

Payload

+ +
+
+
+                        {deliveryEventPayload}
+                      
+
+
+
+ + +
+

Status

+ + {formatDeliveryStatus(selectedDelivery.status)} + +
+
+

Response code

+ {selectedDelivery.responseCode ? ( + + ) : ( + – + )} +
+
+
+

Response payload

+ +
+
+
+                        {deliveryResponsePayload}
+                      
+
+
+
+
+
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx new file mode 100644 index 00000000000..6d84c04bcee --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointDetails.tsx @@ -0,0 +1,175 @@ +import { Search } from 'lucide-react' + +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + Badge, + Card, + CardContent, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookDelivery, WebhookEndpoint } from './PlatformWebhooks.types' +import { statusBadgeVariant } from './PlatformWebhooksView.utils' + +interface DetailItemProps { + label: string + children: React.ReactNode + ddClassName?: string +} + +const DetailItem = ({ label, children, ddClassName = 'text-sm' }: DetailItemProps) => ( +
+
{label}
+
{children}
+
+) + +interface PlatformWebhooksEndpointDetailsProps { + deliverySearch: string + filteredDeliveries: WebhookDelivery[] + selectedEndpoint: WebhookEndpoint + onDeliverySearchChange: (value: string) => void + onOpenDelivery: (deliveryId: string) => void +} + +export const PlatformWebhooksEndpointDetails = ({ + deliverySearch, + filteredDeliveries, + selectedEndpoint, + onDeliverySearchChange, + onOpenDelivery, +}: PlatformWebhooksEndpointDetailsProps) => { + const hasCustomHeaders = selectedEndpoint.customHeaders.length > 0 + + return ( +
+
+

Overview

+ + +
+ + {selectedEndpoint.url} + + + {selectedEndpoint.description || '-'} + + + {(selectedEndpoint.eventTypes.includes('*') + ? ['All events (*)'] + : selectedEndpoint.eventTypes + ).map((eventType) => ( + + {eventType} + + ))} + + + {hasCustomHeaders && ( + +
+ {selectedEndpoint.customHeaders.map((header) => ( +
+ {header.key}: + {header.value} +
+ ))} +
+
+ )} + + {selectedEndpoint.createdBy} + + + + +
+
+
+
+ +
+

Deliveries

+
+ } + value={deliverySearch} + className="w-full lg:w-52" + onChange={(event) => onDeliverySearchChange(event.target.value)} + /> +

{filteredDeliveries.length} total

+
+ + + + + Status + Event type + Response + Attempted + + + + {filteredDeliveries.length > 0 ? ( + filteredDeliveries.map((delivery) => ( + onOpenDelivery(delivery.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpenDelivery(delivery.id) + } + }} + tabIndex={0} + > + + {delivery.status} + + + {delivery.eventType} + + + {delivery.responseCode ? ( + + ) : ( + – + )} + + + + + + )) + ) : ( + + No deliveries found + + )} + +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx new file mode 100644 index 00000000000..41015c161ef --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointList.tsx @@ -0,0 +1,232 @@ +import { ChevronRight, Eye, MoreVertical, Plus, Search, Trash2 } from 'lucide-react' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' + +import { createNavigationHandler } from 'lib/navigation' +import { + Badge, + Button, + Card, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Table, + TableBody, + TableCell, + TableHead, + TableHeadSort, + TableHeader, + TableRow, +} from 'ui' +import { EmptyStatePresentational, TimestampInfo } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import type { WebhookEndpoint } from './PlatformWebhooks.types' + +interface PlatformWebhooksEndpointListProps { + filteredEndpoints: WebhookEndpoint[] + search: string + webhooksHref: string + onCreateEndpoint: () => void + onDeleteEndpoint: (endpointId: string) => void + onSearchChange: (value: string) => void + onViewEndpoint: (endpointId: string) => void +} + +export const PlatformWebhooksEndpointList = ({ + filteredEndpoints, + search, + webhooksHref, + onCreateEndpoint, + onDeleteEndpoint, + onSearchChange, + onViewEndpoint, +}: PlatformWebhooksEndpointListProps) => { + const router = useRouter() + const formatEventCount = (eventTypes: string[]) => { + if (eventTypes.includes('*')) return 'All events' + if (eventTypes.length === 1) return '1 event' + return `${eventTypes.length} events` + } + const [sort, setSort] = useState<'status:asc' | 'status:desc' | 'created:asc' | 'created:desc'>( + 'created:desc' + ) + + const [sortColumn, sortDirection] = sort.split(':') as ['status' | 'created', 'asc' | 'desc'] + + const getAriaSort = (column: 'status' | 'created') => { + if (sortColumn !== column) return 'none' + return sortDirection === 'asc' ? 'ascending' : 'descending' + } + + const handleSortChange = (column: 'status' | 'created') => { + if (sortColumn !== column) { + setSort(`${column}:asc`) + return + } + + setSort(`${column}:${sortDirection === 'asc' ? 'desc' : 'asc'}`) + } + + const sortedEndpoints = useMemo(() => { + const items = [...filteredEndpoints] + + items.sort((a, b) => { + if (sortColumn === 'status') { + const statusA = a.enabled ? 'enabled' : 'disabled' + const statusB = b.enabled ? 'enabled' : 'disabled' + const comparison = statusA.localeCompare(statusB) + + if (comparison !== 0) return sortDirection === 'asc' ? comparison : -comparison + } + + const createdComparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + return sortDirection === 'asc' ? createdComparison : -createdComparison + }) + + return items + }, [filteredEndpoints, sortColumn, sortDirection]) + + return ( +
+
+

Endpoints

+
+
+ } + value={search} + className="w-full lg:w-52" + onChange={(event) => onSearchChange(event.target.value)} + /> + +
+ + {filteredEndpoints.length === 0 ? ( + + + + ) : ( + + + + + + + Status + + + URL + Events + + + Created + + + + + + + {sortedEndpoints.map((endpoint) => ( + + + + {endpoint.enabled ? 'Enabled' : 'Disabled'} + + + +

{endpoint.url}

+ {endpoint.description && ( +

+ {endpoint.description} +

+ )} +
+ + {formatEventCount(endpoint.eventTypes)} + + + +

+ by {endpoint.createdBy} +

+
+ +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + +
+
+
+ ))} +
+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx new file mode 100644 index 00000000000..42bd837657f --- /dev/null +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksEndpointSheet.tsx @@ -0,0 +1,581 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { InlineLink } from 'components/ui/InlineLink' +import { DiscardChangesConfirmationDialog } from 'components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' +import { useConfirmOnClose } from 'hooks/ui/useConfirmOnClose' +import { ChevronDown, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, + Button, + Checkbox_Shadcn_ as Checkbox, + cn, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input_Shadcn_ as InputField, + Label_Shadcn_ as Label, + Separator, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Switch, + TextArea_Shadcn_ as Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' + +import type { + UpsertWebhookEndpointInput, + WebhookEndpoint, + WebhookScope, +} from './PlatformWebhooks.types' + +const endpointFormSchema = z + .object({ + url: z.string().trim().url('Please enter a valid URL'), + description: z.string().trim().max(512, 'Description cannot exceed 512 characters'), + enabled: z.boolean().default(true), + subscribeAll: z.boolean().default(false), + eventTypes: z.array(z.string()).default([]), + customHeaders: z + .array( + z.object({ + key: z.string().trim(), + value: z.string().trim(), + }) + ) + .default([]), + }) + .superRefine((data, ctx) => { + if (!data.subscribeAll && data.eventTypes.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Select at least one event type', + path: ['eventTypes'], + }) + } + }) + +export type EndpointFormValues = z.infer + +const toEventTypes = (values: EndpointFormValues) => + values.subscribeAll ? ['*'] : values.eventTypes + +type EventTypeGroup = { + id: string + label: string + eventTypes: string[] +} + +const buildEventTypeGroups = (scope: WebhookScope, eventTypes: string[]): EventTypeGroup[] => { + if (scope === 'project') { + return [{ id: 'project', label: 'Project events', eventTypes }] + } + + const organizationEvents = eventTypes.filter((eventType) => eventType.startsWith('organization.')) + const projectEvents = eventTypes.filter((eventType) => eventType.startsWith('project.')) + const ungroupedEvents = eventTypes.filter( + (eventType) => !eventType.startsWith('organization.') && !eventType.startsWith('project.') + ) + + return [ + { id: 'organization', label: 'Organization events', eventTypes: organizationEvents }, + { id: 'project', label: 'Project events', eventTypes: projectEvents }, + { id: 'other', label: 'Other events', eventTypes: ungroupedEvents }, + ].filter((group) => group.eventTypes.length > 0) +} + +const toggleEventType = (selectedEventTypes: string[], eventType: string, checked: boolean) => { + if (checked) return [...new Set([...selectedEventTypes, eventType])] + return selectedEventTypes.filter((value) => value !== eventType) +} + +const toggleEventTypeGroup = ( + selectedEventTypes: string[], + groupedEventTypes: string[], + checked: boolean +) => { + if (checked) return [...new Set([...selectedEventTypes, ...groupedEventTypes])] + return selectedEventTypes.filter((value) => !groupedEventTypes.includes(value)) +} + +const toControlId = (prefix: string, value: string) => + `${prefix}-${value.replace(/[^a-zA-Z0-9_-]/g, '-')}` + +export const toEndpointPayload = (values: EndpointFormValues): UpsertWebhookEndpointInput => ({ + name: '', + url: values.url, + description: values.description, + enabled: values.enabled, + eventTypes: toEventTypes(values), + customHeaders: values.customHeaders, +}) + +interface EndpointSheetProps { + visible: boolean + mode: 'create' | 'edit' + scope: WebhookScope + orgSlug?: string + endpoint?: WebhookEndpoint + enabledOverride?: boolean | null + eventTypes: string[] + onClose: () => void + onSubmit: (values: EndpointFormValues) => void +} + +export const PlatformWebhooksEndpointSheet = ({ + visible, + mode, + scope, + orgSlug, + endpoint, + enabledOverride, + eventTypes, + onClose, + onSubmit, +}: EndpointSheetProps) => { + const form = useForm({ + resolver: zodResolver(endpointFormSchema), + defaultValues: { + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }, + }) + const isDirty = form.formState.isDirty + const { + confirmOnClose, + handleOpenChange, + modalProps: discardChangesModalProps, + } = useConfirmOnClose({ + checkIsDirty: () => isDirty, + onClose, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'customHeaders', + }) + + const subscribeAll = form.watch('subscribeAll') + const selectedEventTypes = form.watch('eventTypes') + const groupedEventTypes = useMemo( + () => buildEventTypeGroups(scope, eventTypes), + [scope, eventTypes] + ) + const [openEventGroups, setOpenEventGroups] = useState([]) + + useEffect(() => { + if (!visible) return + + if (!endpoint) { + form.reset({ + url: '', + description: '', + enabled: true, + subscribeAll: false, + eventTypes: [], + customHeaders: [], + }) + return + } + + form.reset({ + url: endpoint.url, + description: endpoint.description, + enabled: enabledOverride ?? endpoint.enabled, + subscribeAll: endpoint.eventTypes.includes('*'), + eventTypes: endpoint.eventTypes.includes('*') ? eventTypes : endpoint.eventTypes, + customHeaders: endpoint.customHeaders.map((header) => ({ + key: header.key, + value: header.value, + })), + }) + }, [enabledOverride, endpoint, eventTypes, form, visible]) + + useEffect(() => { + if (!visible) return + setOpenEventGroups(groupedEventTypes.map((group) => group.id)) + }, [groupedEventTypes, visible]) + + useEffect(() => { + if (!visible) return + const allSelected = + eventTypes.length > 0 && + eventTypes.every((eventType) => selectedEventTypes.includes(eventType)) + + if (subscribeAll !== allSelected) { + form.setValue('subscribeAll', allSelected, { + shouldDirty: true, + shouldValidate: true, + }) + } + }, [eventTypes, form, selectedEventTypes, subscribeAll, visible]) + + return ( + + + + {mode === 'create' ? 'Create endpoint' : 'Edit endpoint'} + + + + +
+
+ ( + + + + + + )} + /> + + ( + + Description (optional) + + } + layout="vertical" + className="gap-1" + > + +