Files
supabase/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksPage.tsx
Monica Khoury 49ff97af06 feat: add global feature preview badge for enabled preview features (#45373)
## What kind of change does this PR introduce?

Fixes FE-2526. 

Adds a global Feature Preview badge to pages enabled via Feature
Previews, improving visibility and making it clearer to users that the
feature can be managed (or disabled) from the Feature Previews settings.

## Why

Previously, once a feature preview was enabled, there was no clear
indication within the UI that:

- the feature was still in preview, or
- where to go to disable it

This lead to confusion and made the feature feel “permanent”.

## What’s included

New FeaturePreviewBadge UI component

<img width="417" height="80" alt="CleanShot 2026-04-29 at 17 20 10"
src="https://github.com/user-attachments/assets/6fbc96e3-35ef-46d1-893a-2188c4d237a3"
/>

</br>

Added badge across pages enabled via Feature Previews:
- Webhooks
- Unified Logs
- JIT DB Access
- Column Privileges
- Policies
- Merge page
- Advisor Rules

Consistent placement and styling.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Feature preview badges now appear across the platform on preview
features, including Platform Webhooks, Database functionality, Unified
Logs, Advisor Rules, and other features, providing quick identification
and access to manage preview settings.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-04-29 20:47:30 +03:00

518 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { EllipsisVertical, Pencil, RotateCw, Trash2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Label_Shadcn_,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { PLATFORM_WEBHOOKS_MOCK_DATA } from './PlatformWebhooks.mock'
import {
filterWebhookDeliveries,
filterWebhookEndpoints,
usePlatformWebhooksMockStore,
} from './PlatformWebhooks.store'
import type { WebhookScope } from './PlatformWebhooks.types'
import { getWebhookEndpointDisplayName } from './PlatformWebhooks.utils'
import { PlatformWebhooksDeliveryDetailsSheet } from './PlatformWebhooksDeliveryDetailsSheet'
import { PlatformWebhooksEndpointDetails } from './PlatformWebhooksEndpointDetails'
import { PlatformWebhooksEndpointList } from './PlatformWebhooksEndpointList'
import {
EndpointFormValues,
PlatformWebhooksEndpointSheet,
toEndpointPayload,
} from './PlatformWebhooksEndpointSheet'
import { PlatformWebhooksHeader } from './PlatformWebhooksHeader'
import {
clearPendingSigningSecretReveal,
getPendingSigningSecretReveal,
setPendingSigningSecretReveal,
shouldHandleEndpointNotFound,
} from './PlatformWebhooksPage.utils'
import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { InlineLink } from '@/components/ui/InlineLink'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
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,
retryDelivery,
} = 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 selectedEndpointHasName = selectedEndpoint ? selectedEndpoint.name.trim().length > 0 : false
const selectedEndpointDisplayName = selectedEndpoint
? getWebhookEndpointDisplayName(selectedEndpoint)
: ''
const headerTitle = isEndpointView ? selectedEndpointDisplayName : scopeLabel
const headerDescription = isEndpointView
? selectedEndpointHasName
? (selectedEndpoint?.url ?? '')
: ''
: scopeDescription
const endpointPendingDelete = useMemo(
() => endpoints.find((endpoint) => endpoint.id === endpointIdPendingDelete) ?? null,
[endpoints, endpointIdPendingDelete]
)
const endpointPendingDeleteHasName = endpointPendingDelete
? endpointPendingDelete.name.trim().length > 0
: false
const endpointPendingDeleteDisplayName = endpointPendingDelete
? getWebhookEndpointDisplayName(endpointPendingDelete)
: ''
let deleteEndpointDescription = 'This action cannot be undone.'
if (endpointPendingDelete) {
deleteEndpointDescription = endpointPendingDeleteHasName
? `Deleting “${endpointPendingDeleteDisplayName}” stops all deliveries to the URL below. This can’t be undone.`
: 'Deleting this endpoint stops all deliveries to the URL below. This can’t be undone.'
}
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('Endpoint deleted')
}
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 handleRetryDelivery = (deliveryId: string) => {
const delivery = deliveries.find((item) => item.id === deliveryId)
if (!delivery || delivery.status === 'success') return
retryDelivery(deliveryId)
toast.success('Delivery queued for retry')
}
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}
featureKey={LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS}
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} className="text-foreground-lighter" />
<span>Regenerate secret</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => setEndpointIdPendingDelete(selectedEndpoint.id)}
>
<Trash2 size={14} className="text-foreground-lighter" />
<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)
}}
onRetryDelivery={handleRetryDelivery}
/>
)}
</PageSectionContent>
</PageSection>
</PageContainer>
<PlatformWebhooksDeliveryDetailsSheet
deliveryAttempt={deliveryAttempt}
deliveryDetailsTab={deliveryDetailsTab}
deliveryEventPayload={deliveryEventPayload}
deliveryResponsePayload={deliveryResponsePayload}
open={!!selectedDelivery}
selectedDelivery={selectedDelivery}
onCopy={handleCopy}
onOpenChange={(open) => !open && setDeliveryId(null)}
onRetryDelivery={handleRetryDelivery}
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>{deleteEndpointDescription}</AlertDialogDescription>
</AlertDialogHeader>
{endpointPendingDelete && (
<pre className="mx-5 -mt-1 mb-5 overflow-auto whitespace-nowrap rounded-md border border-muted bg-surface-200 px-4 py-3 font-mono text-xs tracking-tight text-foreground">
{endpointPendingDelete.url}
</pre>
)}
<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>
</>
)
}