mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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'>>
|
||||
}
|
||||
+187
@@ -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>
|
||||
)
|
||||
}
|
||||
+175
@@ -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>
|
||||
)
|
||||
}
|
||||
+581
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+87
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user