feat(studio): platform webhooks scaffolding (#43276)

## 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.
This commit is contained in:
Danny White authored and GitHub committed 2026-03-06 02:56:54 +00:00
1 parent 5da7ca4c04
commit df55c5c327
33 files changed
+2713 -22

No files matched your search

@@ -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:
@@ -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]
@@ -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]: <UnifiedLogsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_QUEUE_OPERATIONS]: <QueueOperationsPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_TABLE_FILTER_BAR]: <TableFilterBarPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
}
export const FeaturePreviewModal = () => {
@@ -0,0 +1,26 @@
import { useParams } from 'common'
import { InlineLink } from '@/components/ui/InlineLink'
export const PlatformWebhooksPreview = () => {
const { slug = '_', ref = '_' } = useParams()
return (
<div className="space-y-2">
<p className="text-sm text-foreground-light mb-4">
Configure webhook endpoints and review deliveries from both project and organization
settings pages.
</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
Project scope:{' '}
<InlineLink href={`/project/${ref}/settings/webhooks`}>Project Webhooks</InlineLink>
</li>
<li>
Organization scope:{' '}
<InlineLink href={`/org/${slug}/webhooks`}>Organization Webhooks</InlineLink>
</li>
</ul>
</div>
)
}
@@ -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',
@@ -0,0 +1,124 @@
import type { PlatformWebhooksMockSeed, WebhookScope } from './PlatformWebhooks.types'
export const PLATFORM_WEBHOOKS_MOCK_DATA: Record<WebhookScope, PlatformWebhooksMockSeed> = {
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',
},
],
},
}
@@ -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 = <T>(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<Record<WebhookScope, PlatformWebhooksState>> = {}
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<PlatformWebhooksState>(() =>
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
},
}
}
@@ -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<Pick<WebhookHeader, 'key' | 'value'>>
}
@@ -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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent size="default" className="flex flex-col gap-0">
<SheetHeader>
<div className="flex items-center gap-2">
<SheetTitle>Delivery details</SheetTitle>
{selectedDelivery && (
<Badge variant={statusBadgeVariant[selectedDelivery.status]}>
{formatDeliveryStatus(selectedDelivery.status)}
</Badge>
)}
</div>
</SheetHeader>
<Separator />
{selectedDelivery && (
<SheetSection className="overflow-auto flex-grow px-0 py-0">
<div className="space-y-6 p-5">
<Card>
<CardContent className="grid grid-cols-1 gap-4 p-4 @md:grid-cols-2">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Delivery ID</p>
<code className="text-code-inline">{selectedDelivery.id}</code>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Attempt</p>
<p className="text-sm text-foreground">
{deliveryAttempt ? `#${deliveryAttempt}` : '-'}
</p>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Timestamp</p>
<TimestampInfo className="text-sm" utcTimestamp={selectedDelivery.attemptAt} />
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Response code</p>
{selectedDelivery.responseCode ? (
<DataTableColumnStatusCode
value={selectedDelivery.responseCode}
level={getStatusLevel(selectedDelivery.responseCode)}
className="text-xs"
/>
) : (
<span className="text-xs text-foreground-muted">–</span>
)}
</div>
</CardContent>
</Card>
<Tabs
value={deliveryDetailsTab}
onValueChange={(value) => onTabChange(value as 'event' | 'response')}
>
<TabsList className="w-full">
<TabsTrigger value="event" className="flex-1">
Event
</TabsTrigger>
<TabsTrigger value="response" className="flex-1">
Response
</TabsTrigger>
</TabsList>
<TabsContent value="event" className="space-y-4 pt-4">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Event type</p>
<code className="text-code-inline">{selectedDelivery.eventType}</code>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Event ID</p>
<code className="text-code-inline">{selectedDelivery.id}</code>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Event timestamp</p>
<TimestampInfo className="text-sm" utcTimestamp={selectedDelivery.attemptAt} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-foreground-light">Payload</p>
<Button
type="text"
icon={<Copy size={14} />}
onClick={() => onCopy(deliveryEventPayload, 'event payload')}
>
Copy
</Button>
</div>
<div className="rounded-md border border-default bg-surface-200 p-3">
<pre className="whitespace-pre-wrap text-xs text-foreground">
{deliveryEventPayload}
</pre>
</div>
</div>
</TabsContent>
<TabsContent value="response" className="space-y-4 pt-4">
<div className="space-y-1">
<p className="text-sm text-foreground-light">Status</p>
<Badge variant={statusBadgeVariant[selectedDelivery.status]}>
{formatDeliveryStatus(selectedDelivery.status)}
</Badge>
</div>
<div className="space-y-1">
<p className="text-sm text-foreground-light">Response code</p>
{selectedDelivery.responseCode ? (
<DataTableColumnStatusCode
value={selectedDelivery.responseCode}
level={getStatusLevel(selectedDelivery.responseCode)}
className="text-xs"
/>
) : (
<span className="text-xs text-foreground-muted">–</span>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-foreground-light">Response payload</p>
<Button
type="text"
icon={<Copy size={14} />}
onClick={() => onCopy(deliveryResponsePayload, 'response payload')}
>
Copy
</Button>
</div>
<div className="rounded-md border border-default bg-surface-200 p-3">
<pre className="whitespace-pre-wrap text-xs text-foreground">
{deliveryResponsePayload}
</pre>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</SheetSection>
)}
</SheetContent>
</Sheet>
)
}
@@ -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) => (
<div className="space-y-1">
<dt className="text-sm text-foreground-lighter">{label}</dt>
<dd className={ddClassName}>{children}</dd>
</div>
)
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 (
<div className="space-y-16">
<div className="space-y-4">
<h2 className="text-foreground text-xl">Overview</h2>
<Card className="overflow-hidden">
<CardContent className="pb-5">
<dl className="grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2">
<DetailItem label="URL" ddClassName="text-sm break-all">
{selectedEndpoint.url}
</DetailItem>
<DetailItem label="Description">{selectedEndpoint.description || '-'}</DetailItem>
<DetailItem label="Event types" ddClassName="flex flex-wrap gap-2">
{(selectedEndpoint.eventTypes.includes('*')
? ['All events (*)']
: selectedEndpoint.eventTypes
).map((eventType) => (
<code
key={eventType}
className="text-code-inline rounded-md border px-3 py-1.5 text-2xs"
>
{eventType}
</code>
))}
</DetailItem>
{hasCustomHeaders && (
<DetailItem label="Custom headers">
<div className="rounded-md border divide-y divide-border">
{selectedEndpoint.customHeaders.map((header) => (
<div
key={header.id}
className="px-2 py-2 font-mono font-medium text-xs flex items-center gap-2 flex-wrap"
>
<code className="text-code_block-4">{header.key}:</code>
<code className="">{header.value}</code>
</div>
))}
</div>
</DetailItem>
)}
<DetailItem label="Created by">{selectedEndpoint.createdBy}</DetailItem>
<DetailItem label="Created at">
<TimestampInfo className="text-sm" utcTimestamp={selectedEndpoint.createdAt} />
</DetailItem>
</dl>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<h2 className="text-foreground text-xl">Deliveries</h2>
<div className="flex items-center justify-between gap-2">
<Input
placeholder="Search deliveries"
size="tiny"
icon={<Search />}
value={deliverySearch}
className="w-full lg:w-52"
onChange={(event) => onDeliverySearchChange(event.target.value)}
/>
<p className="text-sm text-foreground-muted">{filteredDeliveries.length} total</p>
</div>
<Card className="overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead>Event type</TableHead>
<TableHead>Response</TableHead>
<TableHead>Attempted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDeliveries.length > 0 ? (
filteredDeliveries.map((delivery) => (
<TableRow
key={delivery.id}
className="cursor-pointer inset-focus"
onClick={() => onOpenDelivery(delivery.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenDelivery(delivery.id)
}
}}
tabIndex={0}
>
<TableCell>
<Badge variant={statusBadgeVariant[delivery.status]}>{delivery.status}</Badge>
</TableCell>
<TableCell>
<code className="text-code-inline">{delivery.eventType}</code>
</TableCell>
<TableCell>
{delivery.responseCode ? (
<DataTableColumnStatusCode
value={delivery.responseCode}
level={getStatusLevel(delivery.responseCode)}
className="text-xs"
/>
) : (
<span className="text-xs text-foreground-muted">–</span>
)}
</TableCell>
<TableCell>
<TimestampInfo className="text-sm" utcTimestamp={delivery.attemptAt} />
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4}>No deliveries found</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
</div>
)
}
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h2 className="text-foreground text-xl">Endpoints</h2>
</div>
<div className="flex items-center justify-between gap-x-2">
<Input
placeholder="Search endpoints"
size="tiny"
icon={<Search />}
value={search}
className="w-full lg:w-52"
onChange={(event) => onSearchChange(event.target.value)}
/>
<Button type="primary" icon={<Plus />} onClick={onCreateEndpoint}>
New endpoint
</Button>
</div>
{filteredEndpoints.length === 0 ? (
<EmptyStatePresentational
title="No endpoints yet"
description="Create an endpoint to start receiving webhook deliveries."
>
<Button type="default" onClick={onCreateEndpoint}>
Create endpoint
</Button>
</EmptyStatePresentational>
) : (
<Card className="overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead aria-sort={getAriaSort('status')}>
<TableHeadSort column="status" currentSort={sort} onSortChange={handleSortChange}>
Status
</TableHeadSort>
</TableHead>
<TableHead>URL</TableHead>
<TableHead>Events</TableHead>
<TableHead aria-sort={getAriaSort('created')}>
<TableHeadSort
column="created"
currentSort={sort}
onSortChange={handleSortChange}
>
Created
</TableHeadSort>
</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedEndpoints.map((endpoint) => (
<TableRow
key={endpoint.id}
className="relative cursor-pointer inset-focus"
onClick={createNavigationHandler(
`${webhooksHref}/${encodeURIComponent(endpoint.id)}`,
router
)}
onAuxClick={createNavigationHandler(
`${webhooksHref}/${encodeURIComponent(endpoint.id)}`,
router
)}
onKeyDown={createNavigationHandler(
`${webhooksHref}/${encodeURIComponent(endpoint.id)}`,
router
)}
tabIndex={0}
>
<TableCell>
<Badge variant={endpoint.enabled ? 'success' : 'default'}>
{endpoint.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</TableCell>
<TableCell className="max-w-[420px]">
<p className="truncate">{endpoint.url}</p>
{endpoint.description && (
<p className="text-xs text-foreground-lighter truncate mt-0.5">
{endpoint.description}
</p>
)}
</TableCell>
<TableCell className="max-w-[280px] truncate">
{formatEventCount(endpoint.eventTypes)}
</TableCell>
<TableCell>
<TimestampInfo className="text-sm" utcTimestamp={endpoint.createdAt} />
<p className="text-xs text-foreground-lighter mt-0.5">
by {endpoint.createdBy}
</p>
</TableCell>
<TableCell className="text-right">
<div
className="flex justify-end items-center h-full gap-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<MoreVertical />} className="w-7" />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
onViewEndpoint(endpoint.id)
}}
>
<Eye size={14} />
<span>View details</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => onDeleteEndpoint(endpoint.id)}
>
<Trash2 size={14} />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChevronRight
aria-hidden={true}
size={14}
className="text-foreground-muted/60"
/>
<button tabIndex={-1} className="sr-only">
Go to endpoint details
</button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</div>
)
}
@@ -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<typeof endpointFormSchema>
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<EndpointFormValues>({
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<string[]>([])
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 (
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent showClose={false} size="default" className="flex flex-col gap-0">
<SheetHeader>
<SheetTitle>{mode === 'create' ? 'Create endpoint' : 'Edit endpoint'}</SheetTitle>
</SheetHeader>
<Separator />
<SheetSection className="overflow-auto flex-grow px-0 py-0">
<Form_Shadcn_ {...form}>
<form
id="platform-webhook-endpoint-form"
className="space-y-5 py-5"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="px-5 space-y-5">
<FormField_Shadcn_
control={form.control}
name="url"
render={({ field }) => (
<FormItemLayout label="Endpoint URL" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<InputField
{...field}
placeholder="https://api.example.com/webhooks/supabase"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="description"
render={({ field }) => (
<FormItemLayout
label={
<>
Description <span className="text-foreground-muted">(optional)</span>
</>
}
layout="vertical"
className="gap-1"
>
<FormControl_Shadcn_>
<Textarea
{...field}
rows={4}
placeholder="Optional description for this endpoint"
className="resize-none"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{mode === 'edit' && (
<FormField_Shadcn_
control={form.control}
name="enabled"
render={({ field }) => {
const enabledId = 'enabled-endpoint'
return (
<div className="rounded-md border bg-surface-100">
<Label
htmlFor={enabledId}
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-3"
>
<div className="space-y-0.5">
<p className="text-sm text-foreground">Enable endpoint</p>
<p className="text-sm text-foreground-lighter">
Disabled endpoints won’t receive deliveries
</p>
</div>
<FormControl_Shadcn_>
<Switch
id={enabledId}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</Label>
</div>
)
}}
/>
)}
</div>
<Separator />
<div className="px-5 space-y-3">
<FormItemLayout
label="Event types"
description={
scope === 'organization' ? (
<>
Project events are triggered when any project in this organization matches
the event type. Add a{' '}
<InlineLink href="/project/_/settings/webhooks">
project endpoint
</InlineLink>{' '}
to listen to events on an individual project only.
</>
) : (
<>
Project events are triggered for this project only. Add an{' '}
<InlineLink href={`/org/${orgSlug ?? '_'}/webhooks`}>
organization endpoint
</InlineLink>{' '}
to listen to events from any project in your organization.
</>
)
}
layout="vertical"
className="gap-3"
>
<FormField_Shadcn_
control={form.control}
name="subscribeAll"
render={({ field }) => {
const subscribeAllId = 'subscribe-all-events'
return (
<div className="rounded-md border bg-surface-100 overflow-hidden">
<Label
htmlFor={subscribeAllId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3',
field.value ? 'bg-surface-100' : 'bg-surface-200'
)}
>
<FormControl_Shadcn_>
<Checkbox
id={subscribeAllId}
checked={field.value}
onCheckedChange={(checked) => {
const nextValue = Boolean(checked)
field.onChange(nextValue)
if (nextValue) {
form.setValue('eventTypes', eventTypes, {
shouldDirty: true,
shouldValidate: true,
})
return
}
form.setValue('eventTypes', [], {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<span className="text-sm text-foreground">
Subscribe to all events <code className="text-code-inline">(*)</code>
</span>
</Label>
</div>
)
}}
/>
<FormField_Shadcn_
control={form.control}
name="eventTypes"
render={({ field }) => {
const selectedTypes = field.value ?? []
return (
<>
<FormControl_Shadcn_>
<Accordion
type="multiple"
value={openEventGroups}
onValueChange={setOpenEventGroups}
className="mt-2 space-y-2"
>
{groupedEventTypes.map((group) => {
const selectedInGroup = group.eventTypes.filter((eventType) =>
selectedTypes.includes(eventType)
)
const allSelected =
selectedInGroup.length === group.eventTypes.length
const isGroupOpen = openEventGroups.includes(group.id)
return (
<AccordionItem
key={group.id}
value={group.id}
className="overflow-hidden rounded-md border"
>
<AccordionTrigger
hideIcon
className="group px-4 py-3 hover:no-underline"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm text-foreground-light">
{group.label}
</p>
{selectedInGroup.length > 0 && (
<span className="text-xs text-foreground-muted">
{selectedInGroup.length}
</span>
)}
</div>
<div className="flex items-center gap-3">
{isGroupOpen && group.eventTypes.length > 1 && (
<span
className="text-xs text-foreground-muted hover:text-foreground"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
field.onChange(
toggleEventTypeGroup(
selectedTypes,
group.eventTypes,
!allSelected
)
)
}}
>
{allSelected ? 'Clear all' : 'Select all'}
</span>
)}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pb-0 pt-0 [&>div]:pb-0 [&>div]:pt-0">
<div className="divide-y border-t">
{group.eventTypes.map((eventType) => {
const checked = selectedTypes.includes(eventType)
const eventTypeId = toControlId('event-type', eventType)
return (
<Label
key={eventType}
htmlFor={eventTypeId}
className={cn(
'flex w-full cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-200',
checked && 'bg-surface-100'
)}
>
<Checkbox
id={eventTypeId}
checked={checked}
onCheckedChange={(next) => {
field.onChange(
toggleEventType(
selectedTypes,
eventType,
Boolean(next)
)
)
}}
/>
<code className="text-code-inline">{eventType}</code>
</Label>
)
})}
</div>
</AccordionContent>
</AccordionItem>
)
})}
</Accordion>
</FormControl_Shadcn_>
</>
)
}}
/>
</FormItemLayout>
</div>
<Separator />
<div className="px-5 space-y-3">
<FormItemLayout
label={
<>
Custom headers <span className="text-foreground-muted">(optional)</span>
</>
}
description="Optional HTTP headers sent with every delivery."
layout="vertical"
className="gap-3"
>
{fields.length > 0 && (
<div className="overflow-hidden rounded-md border divide-y mb-3 bg-surface-100">
{fields.map((customHeaderField, index) => (
<div key={customHeaderField.id} className="px-3 py-3">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center">
<FormField_Shadcn_
control={form.control}
name={`customHeaders.${index}.key`}
render={({ field }) => (
<FormControl_Shadcn_>
<InputField
{...field}
placeholder="Header name"
className="font-mono text-xs"
/>
</FormControl_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`customHeaders.${index}.value`}
render={({ field }) => (
<FormControl_Shadcn_>
<InputField
{...field}
placeholder="Header value"
className="font-mono text-xs"
/>
</FormControl_Shadcn_>
)}
/>
<Button
type="text"
htmlType="button"
onClick={() => remove(index)}
icon={<Trash2 size={14} />}
className="justify-self-start sm:justify-self-auto h-full"
/>
</div>
</div>
))}
</div>
)}
<Button
type="default"
htmlType="button"
onClick={() => append({ key: '', value: '' })}
>
Add header
</Button>
</FormItemLayout>
</div>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button form="platform-webhook-endpoint-form" htmlType="submit">
{mode === 'create' ? 'Create endpoint' : 'Save changes'}
</Button>
</SheetFooter>
</SheetContent>
<DiscardChangesConfirmationDialog {...discardChangesModalProps} />
</Sheet>
)
}
@@ -0,0 +1,91 @@
import Link from 'next/link'
import { ReactNode } from 'react'
import { DocsButton } from 'components/ui/DocsButton'
import {
Badge,
BreadcrumbItem_Shadcn_ as BreadcrumbItem,
BreadcrumbLink_Shadcn_ as BreadcrumbLink,
BreadcrumbList_Shadcn_ as BreadcrumbList,
BreadcrumbPage_Shadcn_ as BreadcrumbPage,
BreadcrumbSeparator_Shadcn_ as BreadcrumbSeparator,
Button,
} from 'ui'
import {
PageHeader,
PageHeaderAside,
PageHeaderBreadcrumb,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
interface PlatformWebhooksHeaderProps {
hasSelectedEndpoint: boolean
headerTitle: string
headerDescription: string
endpointStatus?: 'enabled' | 'disabled'
endpointActions?: ReactNode
webhooksHref: string
scopeLabel: string
}
export const PlatformWebhooksHeader = ({
hasSelectedEndpoint,
headerTitle,
headerDescription,
endpointStatus,
endpointActions,
webhooksHref,
scopeLabel,
}: PlatformWebhooksHeaderProps) => {
return (
<PageHeader size="default" className="pb-6">
{hasSelectedEndpoint && (
<PageHeaderBreadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={webhooksHref}>{scopeLabel}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Endpoint</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</PageHeaderBreadcrumb>
)}
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>
<span className="inline-flex items-center gap-2">
<span>{headerTitle}</span>
{hasSelectedEndpoint && endpointStatus && (
<Badge variant={endpointStatus === 'enabled' ? 'success' : 'default'}>
{endpointStatus === 'enabled' ? 'Enabled' : 'Disabled'}
</Badge>
)}
</span>
</PageHeaderTitle>
<PageHeaderDescription>{headerDescription}</PageHeaderDescription>
</PageHeaderSummary>
<PageHeaderAside>
{hasSelectedEndpoint ? (
endpointActions
) : (
<>
<DocsButton href="https://supabase.com/docs" />
<Button asChild type="default">
<a target="_blank" rel="noopener noreferrer" href="https://supabase.com">
Leave feedback
</a>
</Button>
</>
)}
</PageHeaderAside>
</PageHeaderMeta>
</PageHeader>
)
}
@@ -0,0 +1,483 @@
import { useRouter } from 'next/router'
import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { EllipsisVertical, Pencil, RotateCw, Trash2 } from 'lucide-react'
import { useParams } from 'common'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { InlineLink } from 'components/ui/InlineLink'
import { Admonition } from 'ui-patterns'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Label_Shadcn_,
copyToClipboard,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { Input } from 'ui-patterns/DataInputs/Input'
import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
import { PlatformWebhooksEndpointList } from './PlatformWebhooksEndpointList'
import {
EndpointFormValues,
PlatformWebhooksEndpointSheet,
toEndpointPayload,
} from './PlatformWebhooksEndpointSheet'
import {
clearPendingSigningSecretReveal,
getPendingSigningSecretReveal,
setPendingSigningSecretReveal,
shouldHandleEndpointNotFound,
} from './PlatformWebhooksPage.utils'
import { PlatformWebhooksHeader } from './PlatformWebhooksHeader'
import {
filterWebhookDeliveries,
filterWebhookEndpoints,
usePlatformWebhooksMockStore,
} from './PlatformWebhooks.store'
import type { WebhookScope } from './PlatformWebhooks.types'
const PANEL_VALUES = ['create', 'edit'] as const
interface PlatformWebhooksPageProps {
scope: WebhookScope
endpointId?: string
}
export const PlatformWebhooksPage = ({ scope, endpointId }: PlatformWebhooksPageProps) => {
const router = useRouter()
const { slug, ref } = useParams()
const { data: selectedOrganization } = useSelectedOrganizationQuery({
enabled: scope === 'project',
})
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
const {
endpoints,
deliveries,
createEndpoint,
updateEndpoint,
deleteEndpoint,
regenerateSecret,
} = usePlatformWebhooksMockStore(scope)
const [deliveryId, setDeliveryId] = useQueryState('deliveryId', parseAsString)
const [panel, setPanel] = useQueryState('panel', parseAsStringLiteral(PANEL_VALUES))
const [search, setSearch] = useQueryState(
'search',
parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
)
const [deliverySearch, setDeliverySearch] = useQueryState(
'deliverySearch',
parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
)
const [endpointIdPendingDelete, setEndpointIdPendingDelete] = useState<string | null>(null)
const [signingSecretReveal, setSigningSecretReveal] = useState<{ signingSecret: string } | null>(
null
)
const [showRegenerateSecretConfirm, setShowRegenerateSecretConfirm] = useState(false)
const [editEnabledOverride, setEditEnabledOverride] = useState<boolean | null>(null)
const [deliveryDetailsTab, setDeliveryDetailsTab] = useState<'event' | 'response'>('event')
const [pendingCreatedEndpointId, setPendingCreatedEndpointId] = useState<string | null>(null)
const scopeLabel = scope === 'organization' ? 'Organization Webhooks' : 'Project Webhooks'
const scopeDescription =
scope === 'organization'
? 'Organization-level webhook endpoints and deliveries'
: 'Webhook endpoints specific to this project'
const fallbackHref =
scope === 'organization' ? `/org/${slug}/general` : `/project/${ref}/settings/general`
const eventTypeOptions = PLATFORM_WEBHOOKS_MOCK_DATA[scope].eventTypes
const webhooksHref =
scope === 'organization' ? `/org/${slug}/webhooks` : `/project/${ref}/settings/webhooks`
const selectedEndpoint = useMemo(
() => endpoints.find((endpoint) => endpoint.id === endpointId) ?? null,
[endpoints, endpointId]
)
const isEndpointView = !!selectedEndpoint
const headerTitle = isEndpointView ? 'Endpoint' : scopeLabel
const headerDescription = isEndpointView ? selectedEndpoint?.url ?? '' : scopeDescription
const endpointPendingDelete = useMemo(
() => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
[endpoints, endpointIdPendingDelete]
)
useEffect(() => {
if (!platformWebhooksEnabled) {
router.replace(fallbackHref)
}
}, [fallbackHref, platformWebhooksEnabled, router])
useEffect(() => {
if (
shouldHandleEndpointNotFound({
endpointId,
hasSelectedEndpoint: !!selectedEndpoint,
pendingCreatedEndpointId,
})
) {
toast('Endpoint not found')
router.replace(webhooksHref)
}
}, [endpointId, pendingCreatedEndpointId, selectedEndpoint, router, webhooksHref])
useEffect(() => {
if (!pendingCreatedEndpointId) return
if (
endpointId !== pendingCreatedEndpointId ||
selectedEndpoint?.id === pendingCreatedEndpointId
) {
setPendingCreatedEndpointId(null)
}
}, [endpointId, pendingCreatedEndpointId, selectedEndpoint])
useEffect(() => {
if (signingSecretReveal || !endpointId) return
const pendingReveal = getPendingSigningSecretReveal(scope, endpointId)
if (!pendingReveal) return
setSigningSecretReveal({ signingSecret: pendingReveal.signingSecret })
}, [endpointId, scope, signingSecretReveal])
const filteredEndpoints = useMemo(() => {
return filterWebhookEndpoints(endpoints, search)
}, [endpoints, search])
const filteredDeliveries = useMemo(() => {
if (!selectedEndpoint) return []
return filterWebhookDeliveries(deliveries, selectedEndpoint.id, deliverySearch)
}, [deliveries, deliverySearch, selectedEndpoint])
const selectedDelivery = useMemo(() => {
if (!selectedEndpoint || !deliveryId) return null
return (
deliveries.find(
(delivery) => delivery.id === deliveryId && delivery.endpointId === selectedEndpoint.id
) ?? null
)
}, [deliveries, deliveryId, selectedEndpoint])
const deliveryAttempt = useMemo(() => {
if (!selectedEndpoint || !selectedDelivery) return null
const endpointDeliveries = deliveries
.filter((delivery) => delivery.endpointId === selectedEndpoint.id)
.sort((a, b) => new Date(b.attemptAt).getTime() - new Date(a.attemptAt).getTime())
const index = endpointDeliveries.findIndex((delivery) => delivery.id === selectedDelivery.id)
return index >= 0 ? index + 1 : null
}, [deliveries, selectedDelivery, selectedEndpoint])
const deliveryEventPayload = useMemo(() => {
if (!selectedEndpoint || !selectedDelivery) return ''
return JSON.stringify(
{
endpoint_id: selectedEndpoint.id,
endpoint_url: selectedEndpoint.url,
event_type: selectedDelivery.eventType,
event_id: selectedDelivery.id,
attempted_at: selectedDelivery.attemptAt,
scope,
},
null,
2
)
}, [scope, selectedDelivery, selectedEndpoint])
const deliveryResponsePayload = useMemo(() => {
if (!selectedEndpoint || !selectedDelivery) return ''
return JSON.stringify(
{
endpoint_id: selectedEndpoint.id,
delivery_id: selectedDelivery.id,
status: selectedDelivery.status,
response_code: selectedDelivery.responseCode ?? null,
},
null,
2
)
}, [selectedDelivery, selectedEndpoint])
const handleDeleteEndpoint = () => {
if (!endpointPendingDelete) return
deleteEndpoint(endpointPendingDelete.id)
if (endpointPendingDelete.id === endpointId) {
router.push(webhooksHref)
setDeliverySearch('')
}
setEndpointIdPendingDelete(null)
toast.success(`Deleted endpoint "${endpointPendingDelete.url}"`)
}
const handleUpsertEndpoint = (values: EndpointFormValues) => {
if (panel === 'create') {
const { endpointId: createdEndpointId, signingSecret } = createEndpoint(
toEndpointPayload(values)
)
setPendingCreatedEndpointId(createdEndpointId)
setPendingSigningSecretReveal(scope, {
endpointId: createdEndpointId,
signingSecret,
})
router.push(`${webhooksHref}/${encodeURIComponent(createdEndpointId)}`)
setSigningSecretReveal({ signingSecret })
setPanel(null)
setEditEnabledOverride(null)
toast.success('Endpoint created')
return
}
if (panel === 'edit' && selectedEndpoint) {
updateEndpoint(selectedEndpoint.id, toEndpointPayload(values))
setPanel(null)
setEditEnabledOverride(null)
toast.success('Endpoint updated')
}
}
const handleRegenerateSecret = () => {
if (!selectedEndpoint) return
const nextSecret = regenerateSecret(selectedEndpoint.id)
if (!nextSecret) return
setSigningSecretReveal({ signingSecret: nextSecret })
setShowRegenerateSecretConfirm(false)
toast.success('Signing secret regenerated')
}
const handleCopy = (value: string, label: string) => {
copyToClipboard(value)
toast.success(`Copied ${label}`)
}
const isEndpointSheetOpen = panel === 'create' || (panel === 'edit' && !!selectedEndpoint)
useEffect(() => {
if (!selectedEndpoint && !!deliveryId) {
setDeliveryId(null)
}
}, [deliveryId, selectedEndpoint, setDeliveryId])
useEffect(() => {
if (!!deliveryId && !selectedDelivery) {
setDeliveryId(null)
}
}, [deliveryId, selectedDelivery, setDeliveryId])
if (!platformWebhooksEnabled) {
return null
}
return (
<>
<PlatformWebhooksHeader
hasSelectedEndpoint={!!selectedEndpoint}
headerTitle={headerTitle}
headerDescription={headerDescription}
endpointStatus={
selectedEndpoint ? (selectedEndpoint.enabled ? 'enabled' : 'disabled') : undefined
}
endpointActions={
selectedEndpoint ? (
<>
<Button
type="default"
icon={<Pencil size={14} />}
onClick={() => {
setEditEnabledOverride(null)
setPanel('edit')
}}
>
Edit
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" icon={<EllipsisVertical />} className="w-7" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-48">
<DropdownMenuItem
className="gap-x-2"
onClick={() => setShowRegenerateSecretConfirm(true)}
>
<RotateCw size={14} />
<span>Regenerate secret</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => setEndpointIdPendingDelete(selectedEndpoint.id)}
>
<Trash2 size={14} />
<span>Delete endpoint</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : undefined
}
webhooksHref={webhooksHref}
scopeLabel={scopeLabel}
/>
<PageContainer size="default">
<PageSection>
<PageSectionContent>
{!selectedEndpoint ? (
<PlatformWebhooksEndpointList
filteredEndpoints={filteredEndpoints}
search={search}
webhooksHref={webhooksHref}
onCreateEndpoint={() => setPanel('create')}
onDeleteEndpoint={(id) => setEndpointIdPendingDelete(id)}
onSearchChange={setSearch}
onViewEndpoint={(id) => {
router.push(`${webhooksHref}/${encodeURIComponent(id)}`)
setPanel(null)
}}
/>
) : (
<PlatformWebhooksEndpointDetails
deliverySearch={deliverySearch}
filteredDeliveries={filteredDeliveries}
selectedEndpoint={selectedEndpoint}
onDeliverySearchChange={setDeliverySearch}
onOpenDelivery={(id) => {
setDeliveryDetailsTab('event')
setDeliveryId(id)
}}
/>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<PlatformWebhooksDeliveryDetailsSheet
deliveryAttempt={deliveryAttempt}
deliveryDetailsTab={deliveryDetailsTab}
deliveryEventPayload={deliveryEventPayload}
deliveryResponsePayload={deliveryResponsePayload}
open={!!selectedDelivery}
selectedDelivery={selectedDelivery}
onCopy={handleCopy}
onOpenChange={(open) => !open && setDeliveryId(null)}
onTabChange={setDeliveryDetailsTab}
/>
<PlatformWebhooksEndpointSheet
visible={isEndpointSheetOpen}
mode={panel === 'create' ? 'create' : 'edit'}
scope={scope}
orgSlug={scope === 'project' ? selectedOrganization?.slug : undefined}
endpoint={panel === 'edit' ? selectedEndpoint ?? undefined : undefined}
enabledOverride={panel === 'edit' ? editEnabledOverride : null}
eventTypes={eventTypeOptions}
onClose={() => {
setPanel(null)
setEditEnabledOverride(null)
}}
onSubmit={handleUpsertEndpoint}
/>
<AlertDialog
open={!!endpointPendingDelete}
onOpenChange={(open) => !open && setEndpointIdPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete endpoint</AlertDialogTitle>
<AlertDialogDescription>
{endpointPendingDelete
? `This will delete endpoint ${endpointPendingDelete.url}. This action cannot be undone.`
: 'This action cannot be undone.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="danger" onClick={handleDeleteEndpoint}>
Delete endpoint
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showRegenerateSecretConfirm} onOpenChange={setShowRegenerateSecretConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate secret</AlertDialogTitle>
<AlertDialogDescription>
This will rotate the current signing secret used for webhook signature verification.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="warning" onClick={handleRegenerateSecret}>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!signingSecretReveal}
onOpenChange={(open) => {
if (open) return
setSigningSecretReveal(null)
clearPendingSigningSecretReveal(scope)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Signing secret</AlertDialogTitle>
<AlertDialogDescription>
Use this secret to verify webhook signatures using the{' '}
<InlineLink href="https://www.standardwebhooks.com/">Standard Webhooks</InlineLink>{' '}
specification.
</AlertDialogDescription>
</AlertDialogHeader>
{/* Content */}
<div className="space-y-4 mx-5 pb-5">
<div className="space-y-1">
<Label_Shadcn_>Signing secret</Label_Shadcn_>
<Input
copy
readOnly
value={signingSecretReveal?.signingSecret ?? ''}
onChange={() => {}}
onCopy={() => toast.success('Copied signing secret')}
/>
</div>
<div>
<Admonition
type="warning"
title="This secret won’t be shown again"
description="Copy and store it securely now. You will not be able to view or copy it again after closing this dialog."
/>
</div>
</div>
<AlertDialogFooter>
<AlertDialogAction
onClick={() => {
setSigningSecretReveal(null)
clearPendingSigningSecretReveal(scope)
}}
>
I’ve stored the secret
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSigningSecretReveal,
getPendingSigningSecretReveal,
resetPendingSigningSecretRevealForTests,
setPendingSigningSecretReveal,
shouldHandleEndpointNotFound,
} from './PlatformWebhooksPage.utils'
describe('PlatformWebhooksPage.utils', () => {
beforeEach(() => {
resetPendingSigningSecretRevealForTests()
})
it('returns false when endpoint id is absent', () => {
expect(
shouldHandleEndpointNotFound({
endpointId: undefined,
hasSelectedEndpoint: false,
pendingCreatedEndpointId: null,
})
).toBe(false)
})
it('returns false when endpoint is selected', () => {
expect(
shouldHandleEndpointNotFound({
endpointId: 'endpoint-abc12345',
hasSelectedEndpoint: true,
pendingCreatedEndpointId: null,
})
).toBe(false)
})
it('returns false while route is transitioning to a newly created endpoint', () => {
expect(
shouldHandleEndpointNotFound({
endpointId: 'endpoint-abc12345',
hasSelectedEndpoint: false,
pendingCreatedEndpointId: 'endpoint-abc12345',
})
).toBe(false)
})
it('returns true for invalid endpoint routes', () => {
expect(
shouldHandleEndpointNotFound({
endpointId: 'endpoint-missing',
hasSelectedEndpoint: false,
pendingCreatedEndpointId: null,
})
).toBe(true)
})
it('stores and reads pending signing secret reveal for matching endpoint route', () => {
setPendingSigningSecretReveal('project', {
endpointId: 'endpoint-abc12345',
signingSecret: 'whsec_example',
})
expect(getPendingSigningSecretReveal('project', 'endpoint-abc12345')).toEqual({
endpointId: 'endpoint-abc12345',
signingSecret: 'whsec_example',
})
})
it('ignores pending signing secret reveal when endpoint route does not match', () => {
setPendingSigningSecretReveal('project', {
endpointId: 'endpoint-abc12345',
signingSecret: 'whsec_example',
})
expect(getPendingSigningSecretReveal('project', 'endpoint-other')).toBeNull()
})
it('clears pending signing secret reveal', () => {
setPendingSigningSecretReveal('project', {
endpointId: 'endpoint-abc12345',
signingSecret: 'whsec_example',
})
clearPendingSigningSecretReveal('project')
expect(getPendingSigningSecretReveal('project', 'endpoint-abc12345')).toBeNull()
})
})
@@ -0,0 +1,47 @@
import type { WebhookScope } from './PlatformWebhooks.types'
export const shouldHandleEndpointNotFound = ({
endpointId,
hasSelectedEndpoint,
pendingCreatedEndpointId,
}: {
endpointId?: string
hasSelectedEndpoint: boolean
pendingCreatedEndpointId: string | null
}) => {
if (!endpointId) return false
if (hasSelectedEndpoint) return false
return endpointId !== pendingCreatedEndpointId
}
interface PendingSigningSecretReveal {
endpointId: string
signingSecret: string
}
const pendingSigningSecretRevealByScope: Partial<Record<WebhookScope, PendingSigningSecretReveal>> =
{}
export const setPendingSigningSecretReveal = (
scope: WebhookScope,
value: PendingSigningSecretReveal
) => {
pendingSigningSecretRevealByScope[scope] = value
}
export const getPendingSigningSecretReveal = (scope: WebhookScope, endpointId?: string) => {
const pending = pendingSigningSecretRevealByScope[scope]
if (!pending) return null
if (endpointId && pending.endpointId !== endpointId) return null
return pending
}
export const clearPendingSigningSecretReveal = (scope: WebhookScope) => {
delete pendingSigningSecretRevealByScope[scope]
}
export const resetPendingSigningSecretRevealForTests = () => {
for (const key of Object.keys(pendingSigningSecretRevealByScope) as WebhookScope[]) {
delete pendingSigningSecretRevealByScope[key]
}
}
@@ -0,0 +1,31 @@
import type { WebhookDeliveryStatus } from './PlatformWebhooks.types'
export const statusBadgeVariant: Record<
WebhookDeliveryStatus,
'default' | 'success' | 'destructive'
> = {
pending: 'default',
success: 'success',
failure: 'destructive',
skipped: 'default',
}
export const formatDate = (value: string) =>
new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value)
)
export const formatEvents = (eventTypes: string[]) =>
eventTypes.includes('*') ? 'All events (*)' : eventTypes.join(', ')
export const formatDeliveryStatus = (status: WebhookDeliveryStatus) =>
`${status.charAt(0).toUpperCase()}${status.slice(1)}`
export const responseCodeBadgeVariant = (
responseCode?: number
): 'default' | 'success' | 'destructive' => {
if (!responseCode) return 'default'
if (responseCode >= 200 && responseCode < 300) return 'success'
if (responseCode >= 400) return 'destructive'
return 'default'
}
@@ -0,0 +1,11 @@
export { PlatformWebhooksPage } from './PlatformWebhooksPage'
export { usePlatformWebhooksMockStore } from './PlatformWebhooks.store'
export type {
PlatformWebhooksState,
UpsertWebhookEndpointInput,
WebhookDelivery,
WebhookDeliveryStatus,
WebhookEndpoint,
WebhookHeader,
WebhookScope,
} from './PlatformWebhooks.types'
@@ -45,6 +45,7 @@ import {
import { Route } from '../ui/ui.types'
import {
useIsAPIDocsSidePanelEnabled,
useIsPlatformWebhooksEnabled,
useUnifiedLogsPreview,
} from './App/FeaturePreview/FeaturePreviewContext'
@@ -225,6 +226,7 @@ const ProjectLinks = () => {
const { mutate: sendEvent } = useSendEventMutation()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const activeRoute = router.pathname.split('/')[3]
@@ -256,7 +258,9 @@ const ProjectLinks = () => {
showReports,
apiDocsSidePanel: isNewAPIDocsEnabled,
})
const settingsRoutes = generateSettingsRoutes(ref, project)
const settingsRoutes = generateSettingsRoutes(ref, project, {
platformWebhooks: platformWebhooksEnabled,
})
return (
<SidebarMenu>
@@ -89,13 +89,6 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
} as IRouteCommand,
]
: []),
{
id: 'nav-database-hooks',
name: 'Webhooks',
value: 'Database: Webhooks',
route: `/project/${ref}/integrations/webhooks`,
defaultHidden: true,
},
{
id: 'nav-database-backups',
name: 'Backups',
@@ -93,7 +93,7 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => {
rightIcon: ExternalLinkIcon,
},
pgNetExtensionExists && {
name: 'Webhooks',
name: 'Database Webhooks',
key: 'hooks',
url: `/project/${ref}/integrations/webhooks/overview`,
rightIcon: ExternalLinkIcon,
@@ -182,8 +182,14 @@ export const generateOtherRoutes = (
]
}
export const generateSettingsRoutes = (ref?: string, project?: Project): Route[] => {
const settingsMenu = generateSettingsMenu(ref as string)
export const generateSettingsRoutes = (
ref?: string,
project?: Project,
features?: { platformWebhooks?: boolean }
): Route[] => {
const settingsMenu = generateSettingsMenu(ref as string, undefined, undefined, {
platformWebhooks: features?.platformWebhooks,
})
return [
{
key: 'settings',
@@ -1,11 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
generateOrganizationSettingsMenuItems,
generateOrganizationSettingsSections,
getOrganizationSettingsDocumentTitle,
normalizeOrganizationSettingsPath,
} from './OrganizationSettingsLayout'
describe('generateOrganizationSettingsMenuItems', () => {
it('includes webhooks entry for organization settings nav', () => {
const items = generateOrganizationSettingsMenuItems({
slug: 'my-org',
showSecuritySettings: true,
showSsoSettings: true,
showLegalDocuments: true,
})
expect(items.some((item) => item.label === 'Webhooks')).toBe(true)
expect(items.some((item) => item.href === '/org/my-org/webhooks')).toBe(true)
})
})
describe('OrganizationSettingsLayout helpers', () => {
it('returns expected organization settings sections and links', () => {
const sections = generateOrganizationSettingsSections({
@@ -26,6 +41,7 @@ describe('OrganizationSettingsLayout helpers', () => {
'Security',
'SSO',
'OAuth Apps',
'Webhooks',
'Audit Logs',
'Legal Documents',
])
@@ -52,6 +68,7 @@ describe('OrganizationSettingsLayout helpers', () => {
expect(sections.flatMap((section) => section.links.map((item) => item.label))).toEqual([
'General',
'OAuth Apps',
'Webhooks',
'Audit Logs',
])
})
@@ -72,6 +89,21 @@ describe('OrganizationSettingsLayout helpers', () => {
).toBe(true)
})
it('keeps webhooks nav item active for nested endpoint routes', () => {
const sections = generateOrganizationSettingsSections({
slug: 'my-org',
currentPath: '/org/my-org/webhooks/org-endpoint-1',
showSecuritySettings: true,
showSsoSettings: true,
showLegalDocuments: true,
})
expect(
sections.flatMap((section) => section.links).find((item) => item.label === 'Webhooks')
?.isActive
).toBe(true)
})
it('uses settings as default document title when page title is not provided', () => {
expect(getOrganizationSettingsDocumentTitle(undefined, 'Supabase')).toBe('Settings | Supabase')
})
@@ -2,18 +2,23 @@ import Head from 'next/head'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import type { SidebarSection } from 'components/layouts/AccountLayout/AccountLayout.types'
import { WithSidebar } from 'components/layouts/AccountLayout/WithSidebar'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useCurrentPath } from 'hooks/misc/useCurrentPath'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
interface OrganizationSettingsSectionsProps {
interface OrganizationSettingsMenuItemsProps {
slug?: string
currentPath: string
showSecuritySettings?: boolean
showSsoSettings?: boolean
showLegalDocuments?: boolean
showPlatformWebhooks?: boolean
}
interface OrganizationSettingsSectionsProps extends OrganizationSettingsMenuItemsProps {
currentPath: string
}
interface OrganizationSettingsLayoutProps {
@@ -27,13 +32,79 @@ export const getOrganizationSettingsDocumentTitle = (
title: string
) => `${getOrganizationSettingsPageTitle(pageTitle)} | ${title}`
export const generateOrganizationSettingsSections = ({
export const generateOrganizationSettingsMenuItems = ({
slug,
currentPath,
showSecuritySettings = true,
showSsoSettings = true,
showLegalDocuments = true,
showPlatformWebhooks = true,
}: OrganizationSettingsMenuItemsProps) => [
{
key: 'general',
label: 'General',
href: `/org/${slug}/general`,
},
...(showSecuritySettings
? [
{
key: 'security',
label: 'Security',
href: `/org/${slug}/security`,
},
]
: []),
{
key: 'apps',
label: 'OAuth Apps',
href: `/org/${slug}/apps`,
},
...(showSsoSettings
? [
{
key: 'sso',
label: 'SSO',
href: `/org/${slug}/sso`,
},
]
: []),
...(showPlatformWebhooks
? [
{
key: 'webhooks',
label: 'Webhooks',
href: `/org/${slug}/webhooks`,
},
]
: []),
{
key: 'audit',
label: 'Audit Logs',
href: `/org/${slug}/audit`,
},
...(showLegalDocuments
? [
{
key: 'documents',
label: 'Legal Documents',
href: `/org/${slug}/documents`,
},
]
: []),
]
export const generateOrganizationSettingsSections = ({
currentPath,
slug,
showSecuritySettings = true,
showSsoSettings = true,
showLegalDocuments = true,
showPlatformWebhooks = true,
}: OrganizationSettingsSectionsProps): SidebarSection[] => {
const isLinkActive = (key: string, href: string) =>
key === 'webhooks'
? currentPath === href || currentPath.startsWith(`${href}/`)
: currentPath === href
const configurationLinks = [
{
key: 'general',
@@ -66,6 +137,15 @@ export const generateOrganizationSettingsSections = ({
label: 'OAuth Apps',
href: `/org/${slug}/apps`,
},
...(showPlatformWebhooks
? [
{
key: 'webhooks',
label: 'Webhooks',
href: `/org/${slug}/webhooks`,
},
]
: []),
]
const complianceLinks = [
@@ -91,7 +171,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Configuration',
links: configurationLinks.map((item) => ({
...item,
isActive: currentPath === item.href,
isActive: isLinkActive(item.key, item.href),
})),
},
{
@@ -99,7 +179,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Connections',
links: connectionsLinks.map((item) => ({
...item,
isActive: currentPath === item.href,
isActive: isLinkActive(item.key, item.href),
})),
},
{
@@ -107,7 +187,7 @@ export const generateOrganizationSettingsSections = ({
heading: 'Compliance',
links: complianceLinks.map((item) => ({
...item,
isActive: currentPath === item.href,
isActive: isLinkActive(item.key, item.href),
})),
},
]
@@ -118,6 +198,7 @@ function OrganizationSettingsLayout({
pageTitle,
}: PropsWithChildren<OrganizationSettingsLayoutProps>) {
const { slug } = useParams()
const showPlatformWebhooks = useIsPlatformWebhooksEnabled()
const fullCurrentPath = useCurrentPath()
const currentPath = normalizeOrganizationSettingsPath(fullCurrentPath)
const { appTitle } = useCustomContent(['app:title'])
@@ -134,11 +215,12 @@ function OrganizationSettingsLayout({
])
const sections = generateOrganizationSettingsSections({
slug,
currentPath,
slug,
showSecuritySettings,
showSsoSettings,
showLegalDocuments,
showPlatformWebhooks,
})
return (
@@ -1,4 +1,5 @@
import { useParams } from 'common'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useRouter } from 'next/router'
import type { CommandOptions, ICommand } from 'ui-patterns/CommandMenu'
import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
@@ -10,8 +11,10 @@ import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
export function useProjectSettingsGotoCommands(options?: CommandOptions) {
const router = useRouter()
const setIsOpen = useSetCommandMenuOpen()
let { ref } = useParams()
let { ref, slug } = useParams()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
ref ||= '_'
const hasOrgSlug = typeof slug === 'string' && slug.length > 0 && slug !== '_'
const { projectSettingsLogDrains, projectSettingsCustomDomains, authenticationSignInProviders } =
useIsFeatureEnabled([
@@ -43,6 +46,26 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) {
: `/project/${ref}/auth/policies`,
defaultHidden: true,
},
...(platformWebhooksEnabled
? [
{
id: 'nav-project-settings-webhooks',
name: 'Project Webhooks',
route: `/project/${ref}/settings/webhooks`,
defaultHidden: true,
} as IRouteCommand,
...(hasOrgSlug
? [
{
id: 'nav-organization-settings-webhooks',
name: 'Organization Webhooks',
route: `/org/${slug}/webhooks`,
defaultHidden: true,
} as IRouteCommand,
]
: []),
]
: []),
{
id: 'nav-project-settings-api',
name: 'API Settings',
@@ -146,6 +169,6 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) {
]
: []),
],
{ ...options, deps: [ref] }
{ ...options, deps: [platformWebhooksEnabled, ref, slug] }
)
}
@@ -2,6 +2,7 @@ import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -19,6 +20,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const platformWebhooksEnabled = useIsPlatformWebhooksEnabled()
// billing pages live under /billing/invoices and /billing/subscription, etc
// so we need to pass the [5]th part of the url to the menu
@@ -55,6 +57,7 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
legacyJwtKeys: legacyJWTKeysEnabled,
logDrains: projectSettingsLogDrains,
billing: billingAll,
platformWebhooks: platformWebhooksEnabled,
})
return (
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('lib/constants', async () => {
const actual = await vi.importActual<Record<string, unknown>>('lib/constants')
return {
...actual,
IS_PLATFORM: true,
}
})
import { generateSettingsMenu } from './SettingsMenu.utils'
describe('generateSettingsMenu', () => {
it('includes webhooks in project settings items', () => {
const menu = generateSettingsMenu(
'project-ref',
{ status: 'ACTIVE_HEALTHY' } as any,
{ slug: 'my-org' } as any,
{ platformWebhooks: true }
)
const configurationGroup = menu.find((group) => group.title === 'Configuration')
const hasWebhooks = configurationGroup?.items.some(
(item) => item.name === 'Webhooks' && item.url === '/project/project-ref/settings/webhooks'
)
expect(hasWebhooks).toBe(true)
})
it('hides webhooks when platformWebhooks feature is disabled', () => {
const menu = generateSettingsMenu(
'project-ref',
{ status: 'ACTIVE_HEALTHY' } as any,
{ slug: 'my-org' } as any,
{ platformWebhooks: false }
)
const configurationGroup = menu.find((group) => group.title === 'Configuration')
const hasWebhooks = configurationGroup?.items.some((item) => item.name === 'Webhooks')
expect(hasWebhooks).toBe(false)
})
})
@@ -17,6 +17,7 @@ export const generateSettingsMenu = (
legacyJwtKeys?: boolean
logDrains?: boolean
billing?: boolean
platformWebhooks?: boolean
}
): ProductMenuGroup[] => {
if (!IS_PLATFORM) {
@@ -39,6 +40,7 @@ export const generateSettingsMenu = (
const legacyJwtKeysEnabled = features?.legacyJwtKeys ?? true
const billingEnabled = features?.billing ?? true
const platformWebhooksEnabled = features?.platformWebhooks ?? false
return [
{
@@ -72,6 +74,17 @@ export const generateSettingsMenu = (
items: [],
disabled: !isProjectActive,
},
...(platformWebhooksEnabled
? [
{
name: 'Webhooks',
key: 'webhooks',
url: `/project/${ref}/settings/webhooks`,
items: [],
disabled: !isProjectActive,
},
]
: []),
{
name: 'API Keys',
@@ -0,0 +1,24 @@
import { useRouter } from 'next/router'
import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
import DefaultLayout from 'components/layouts/DefaultLayout'
import OrganizationLayout from 'components/layouts/OrganizationLayout'
import OrganizationSettingsLayout from 'components/layouts/ProjectLayout/OrganizationSettingsLayout'
import type { NextPageWithLayout } from 'types'
const OrgWebhookEndpointSettings: NextPageWithLayout = () => {
const { query } = useRouter()
const endpointId = Array.isArray(query.endpointId) ? query.endpointId[0] : query.endpointId
return <PlatformWebhooksPage scope="organization" endpointId={endpointId} />
}
OrgWebhookEndpointSettings.getLayout = (page) => (
<DefaultLayout>
<OrganizationLayout>
<OrganizationSettingsLayout>{page}</OrganizationSettingsLayout>
</OrganizationLayout>
</DefaultLayout>
)
export default OrgWebhookEndpointSettings
@@ -0,0 +1,19 @@
import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
import DefaultLayout from 'components/layouts/DefaultLayout'
import OrganizationLayout from 'components/layouts/OrganizationLayout'
import OrganizationSettingsLayout from 'components/layouts/ProjectLayout/OrganizationSettingsLayout'
import type { NextPageWithLayout } from 'types'
const OrgWebhooksSettings: NextPageWithLayout = () => {
return <PlatformWebhooksPage scope="organization" />
}
OrgWebhooksSettings.getLayout = (page) => (
<DefaultLayout>
<OrganizationLayout>
<OrganizationSettingsLayout>{page}</OrganizationSettingsLayout>
</OrganizationLayout>
</DefaultLayout>
)
export default OrgWebhooksSettings
@@ -0,0 +1,21 @@
import { useRouter } from 'next/router'
import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
import DefaultLayout from 'components/layouts/DefaultLayout'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import type { NextPageWithLayout } from 'types'
const ProjectWebhookEndpointSettings: NextPageWithLayout = () => {
const { query } = useRouter()
const endpointId = Array.isArray(query.endpointId) ? query.endpointId[0] : query.endpointId
return <PlatformWebhooksPage scope="project" endpointId={endpointId} />
}
ProjectWebhookEndpointSettings.getLayout = (page) => (
<DefaultLayout>
<SettingsLayout>{page}</SettingsLayout>
</DefaultLayout>
)
export default ProjectWebhookEndpointSettings
@@ -0,0 +1,16 @@
import { PlatformWebhooksPage } from 'components/interfaces/Platform/Webhooks'
import DefaultLayout from 'components/layouts/DefaultLayout'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import type { NextPageWithLayout } from 'types'
const ProjectWebhooksSettings: NextPageWithLayout = () => {
return <PlatformWebhooksPage scope="project" />
}
ProjectWebhooksSettings.getLayout = (page) => (
<DefaultLayout>
<SettingsLayout>{page}</SettingsLayout>
</DefaultLayout>
)
export default ProjectWebhooksSettings
@@ -24,6 +24,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_QUEUE_OPERATIONS: 'supabase-ui-queue-operations',
UI_PREVIEW_TABLE_FILTER_BAR: 'supabase-ui-table-filter-bar',
UI_PREVIEW_PG_DELTA_DIFF: 'supabase-ui-pg-delta-diff',
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
NEW_LAYOUT_NOTICE_ACKNOWLEDGED: 'new-layout-notice-acknowledge',
TABS_INTERFACE_ACKNOWLEDGED: 'tabs-interface-acknowledge',
@@ -142,6 +143,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
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,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,