Files
supabase/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx
Vaibhav 1cffe632e3 fix: webhook apikey (#47317)
## TL;DR
Database webhooks/Cron jobs now add `apikey: <secret-key>` for edge
function auth..

## ref:
- related to: https://github.com/supabase/supabase/pull/46890
- towards COM-269

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

## New Features
* Improved edge function webhook authentication by automatically
selecting the appropriate API key or authorization header format.
* Authorization headers are now added or normalized when required, while
preserving existing custom headers and supported credentials.

## Improvements
* Simplified “Add header” and “Add parameter” controls with clearer
labels.
* Updated authentication actions to clearly describe the selected header
type.

## Tests
* Expanded coverage for key formats, authorization behavior, header
preservation, and revised control labels.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Tomás Pozo <tomaspozo@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-08-24 10:06:36 -06:00

285 lines
11 KiB
TypeScript

import type { PGTrigger } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import Image from 'next/legacy/image'
import { useEffect } from 'react'
import { UseFormReturn } from 'react-hook-form'
import {
Checkbox,
FormControl,
FormField,
Input,
Label,
RadioGroupStacked,
RadioGroupStackedItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SidePanel,
useWatch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { WebhookFormValues } from './EditHookPanel.constants'
import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants'
import { HTTPHeaders } from './HTTPHeaders'
import { HTTPParameters } from './HTTPParameters'
import { HTTPRequestConfig } from './HTTPRequestConfig'
import { ensureEdgeFunctionAuthorizationHeader } from '@/components/interfaces/Functions/httpHeaderAddActions'
import {
FormSection,
FormSectionContent,
FormSectionLabel,
} from '@/components/ui/Forms/FormSection'
import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useTableNamesQuery } from '@/data/tables/table-names-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { buildDatabaseEdgeFunctionUrl, isEdgeFunctionUrl } from '@/lib/api/edgeFunctions'
import { uuidv4 } from '@/lib/helpers'
export interface FormContentsProps {
form: UseFormReturn<WebhookFormValues>
selectedHook?: PGTrigger
}
export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const restUrl = project?.restUrl
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: keys = [] } = useAPIKeysQuery(
{ projectRef: ref, reveal: true },
{ enabled: canReadAPIKeys }
)
const { data: functions = [], isSuccess: isSuccessEdgeFunctions } = useEdgeFunctionsQuery({
projectRef: ref,
})
const { serviceKey } = getKeys(keys)
const serviceRoleKey = serviceKey?.api_key
const httpUrl = useWatch({ control: form.control, name: 'http_url' })
const httpHeaders = useWatch({ control: form.control, name: 'httpHeaders' })
const { data: tables = [] } = useTableNamesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
// Handle auth header auto-add for edge functions
useEffect(() => {
if (!isSuccessEdgeFunctions) return
const isEdgeFunctionSelected = isEdgeFunctionUrl(httpUrl, ref ?? '', restUrl)
if (httpUrl && isEdgeFunctionSelected) {
const fnSlug = httpUrl.split('/').at(-1)
const fn = functions.find((x) => x.slug === fnSlug)
const updatedHttpHeaders = ensureEdgeFunctionAuthorizationHeader({
headers: httpHeaders,
serviceRoleKey,
verifyJwt: fn?.verify_jwt,
createRow: (name, value) => ({ id: uuidv4(), name, value }),
})
if (updatedHttpHeaders !== httpHeaders) {
form.setValue('httpHeaders', updatedHttpHeaders)
}
}
}, [form, functions, httpHeaders, httpUrl, isSuccessEdgeFunctions, serviceRoleKey, ref, restUrl])
return (
<div>
<FormSection header={<FormSectionLabel className="lg:col-span-4!">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:col-span-8!">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1">
<FormControl>
<Input {...field} placeholder="my_webhook" />
</FormControl>
<p className="mt-2 text-xs text-foreground-lighter">
Do not use spaces/whitespaces
</p>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:col-span-4!"
description={
<p className="text-sm text-foreground-light">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:col-span-8!">
<FormField
control={form.control}
name="table_id"
render={({ field }) => (
<FormItemLayout
label="Table"
layout="vertical"
className="gap-1"
description="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a table" />
</SelectTrigger>
</FormControl>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.id} value={table.id.toString()}>
<div className="flex items-center space-x-2">
<span className="text-foreground-light">{table.schema}</span>
<span className="text-foreground">{table.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name="events"
render={({ field }) => (
<FormItemLayout
label="Events"
layout="vertical"
className="gap-1"
description="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
<div className="space-y-3">
{HOOK_EVENTS.map((event) => (
<div key={event.value} className="flex items-start space-x-3">
<Checkbox
id={`event-${event.value}`}
checked={field.value.includes(event.value)}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...field.value, event.value])
} else {
field.onChange(field.value.filter((v) => v !== event.value))
}
}}
/>
<div className="grid gap-1.5 leading-none">
<Label
htmlFor={`event-${event.value}`}
className="text-sm font-normal cursor-pointer"
>
{event.label}
</Label>
<p className="text-xs text-foreground-lighter">{event.description}</p>
</div>
</div>
))}
</div>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:col-span-4!">Webhook configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:col-span-8!">
<FormField
control={form.control}
name="function_type"
render={({ field }) => (
<FormItemLayout label="Type of webhook" layout="vertical" className="gap-1">
<FormControl>
<RadioGroupStacked
value={field.value}
onValueChange={(functionType) => {
if (functionType === 'http_request') {
if (selectedHook !== undefined) {
const [url] = selectedHook.function_args
form.setValue('http_url', url, { shouldDirty: false })
} else {
form.setValue('http_url', '', { shouldDirty: false })
}
} else if (functionType === 'supabase_function') {
// Default to first edge function in the list
const fnSlug = functions[0]?.slug
const defaultFunctionUrl = buildDatabaseEdgeFunctionUrl(
fnSlug ?? '',
ref ?? '',
restUrl
)
const currentUrl = form.getValues('http_url')
if (!isEdgeFunctionUrl(currentUrl, ref ?? '', restUrl)) {
form.setValue('http_url', defaultFunctionUrl, { shouldDirty: false })
}
}
field.onChange(functionType)
}}
>
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<RadioGroupStackedItem
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
showIndicator={false}
>
<div className="flex items-center gap-5">
<Image
alt={webhook.label}
src={webhook.icon}
layout="fixed"
width="32"
height="32"
/>
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-foreground">{webhook.label}</p>
</div>
<p className="text-foreground-light">{webhook.description}</p>
</div>
</div>
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormControl>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestConfig form={form} />
<SidePanel.Separator />
<HTTPHeaders form={form} />
<SidePanel.Separator />
<HTTPParameters form={form} />
</div>
)
}