mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## 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 -->
518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
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>
|
||
</>
|
||
)
|
||
}
|