all of it

This commit is contained in:
Saxon Fletcher committed 2025-02-16 08:05:04 +10:00
1 parent 70583bf968
commit e36233cfe6
14 files changed
+703 -225

No files matched your search

@@ -4,14 +4,14 @@ import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { object, string, boolean } from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { FormFieldWrapper } from 'components/ui/Forms'
import { SectionHeader } from 'components/layouts/PageLayout'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
@@ -23,16 +23,27 @@ import {
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
CriticalIcon,
Form,
Form_Shadcn_,
Input,
Modal,
Switch,
Toggle,
cn,
} from 'ui'
import CommandRender from '../CommandRender'
import { generateCLICommands } from './EdgeFunctionDetails.utils'
const schema = object({
name: string().required('Name is required'),
verify_jwt: boolean().required(),
})
const EdgeFunctionDetails = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
@@ -50,7 +61,6 @@ const EdgeFunctionDetails = () => {
},
})
const formId = 'edge-function-update-form'
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
const { anonKey } = getAPIKeys(settings)
@@ -69,7 +79,24 @@ const EdgeFunctionDetails = () => {
anonKey: apiKey,
})
const onUpdateFunction = async (values: any, { resetForm }: any) => {
const form = useForm({
resolver: yupResolver(schema),
defaultValues: {
name: '',
verify_jwt: false,
},
})
useEffect(() => {
if (selectedFunction) {
form.reset({
name: selectedFunction.name,
verify_jwt: selectedFunction.verify_jwt,
})
}
}, [selectedFunction])
const onUpdateFunction = async (values: any) => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFunction === undefined) return console.error('No edge function selected')
@@ -81,7 +108,6 @@ const EdgeFunctionDetails = () => {
},
{
onSuccess: () => {
resetForm({ values, initialValues: values })
toast.success(`Successfully updated edge function`)
},
}
@@ -101,173 +127,183 @@ const EdgeFunctionDetails = () => {
return (
<>
<div className="space-y-4 pb-16 pt-3">
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
{({ handleReset, values, initialValues, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (selectedFunction !== undefined) {
const formValues = {
name: selectedFunction?.name,
verify_jwt: selectedFunction?.verify_jwt,
}
resetForm({ values: formValues, initialValues: formValues })
}
}, [selectedFunction])
return (
<>
<FormPanel
disabled={!canUpdateEdgeFunction}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!canUpdateEdgeFunction
? 'You need additional permissions to update this function'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Function Details</FormSectionLabel>}>
<FormSectionContent loading={selectedFunction === undefined}>
<Input id="name" name="name" label="Name" disabled={!canUpdateEdgeFunction} />
<Input
disabled
id="slug"
name="slug"
label="Slug"
value={selectedFunction?.slug}
/>
<Input disabled copy label="Endpoint URL" value={functionUrl} />
<Input disabled label="Region" value="All functions are deployed globally" />
<Input
disabled
label="Created at"
value={dayjs(selectedFunction?.created_at ?? 0).format(
'dddd, MMMM D, YYYY h:mm A'
)}
/>
<Input
disabled
label="Last updated at"
value={dayjs(selectedFunction?.updated_at ?? 0).format(
'dddd, MMMM D, YYYY h:mm A'
)}
/>
<Input disabled label="Deployments" value={selectedFunction?.version ?? 0} />
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Function Configuration</FormSectionLabel>}>
<FormSectionContent loading={selectedFunction === undefined}>
<Toggle
id="verify_jwt"
name="verify_jwt"
<SectionHeader title="Function Configuration" />
<div className="mx-auto flex flex-col xl:flex-row gap-8">
<div className="flex-1 space-y-6">
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
<Card>
<CardContent>
<FormFieldWrapper
name="name"
control={form.control}
label="Name"
description="Your slug and endpoint URL will remain the same"
>
{(field) => (
<Input {...field} className="w-full" disabled={!canUpdateEdgeFunction} />
)}
</FormFieldWrapper>
</CardContent>
<CardContent>
<FormFieldWrapper
name="verify_jwt"
control={form.control}
label="Enforce JWT Verification"
description="Require a valid JWT in the authorization header when invoking the function"
>
{(field) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateEdgeFunction}
label="Enforce JWT Verification"
descriptionText="Require a valid JWT in the authorization header when invoking the function"
/>
<div className="space-y-1 border rounded border-default bg-surface-200 px-4 py-4">
<div className="flex items-center space-x-2">
<p className="text-sm">
Import maps are{' '}
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
</p>
</div>
<p className="text-sm text-foreground-light">
Import maps allow the use of bare specifiers in functions instead of
explicit import URLs
</p>
<div className="!mt-4">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/functions/dependencies"
target="_blank"
rel="noreferrer"
>
More about import maps
</Link>
</Button>
</div>
</div>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
)}
</FormFieldWrapper>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
<div
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
style={{ maxHeight: showInstructions ? 800 : 66 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
<Terminal size={18} strokeWidth={2} />
<div
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
style={{ maxHeight: showInstructions ? 800 : 66 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
<Terminal size={18} strokeWidth={2} />
</div>
<h4>Command line access</h4>
</div>
<div
className="cursor-pointer"
onClick={() => setShowInstructions(!showInstructions)}
>
{showInstructions ? (
<Minimize2 size={14} strokeWidth={1.5} />
) : (
<Maximize2 size={14} strokeWidth={1.5} />
)}
</div>
<h4>Command line access</h4>
</div>
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
{showInstructions ? (
<Minimize2 size={14} strokeWidth={1.5} />
) : (
<Maximize2 size={14} strokeWidth={1.5} />
)}
</div>
<h5 className="text-base">Deployment management</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke </h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Secrets management</h5>
<CommandRender commands={secretCommands} />
</div>
<h5 className="text-base">Deployment management</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke </h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Secrets management</h5>
<CommandRender commands={secretCommands} />
<div className="!mt-8">
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>
Once your function is deleted, it can no longer be restored
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Make sure you have made a backup if you want to restore your edge function
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button
type="danger"
disabled={!canUpdateEdgeFunction}
loading={selectedFunction?.id === undefined}
onClick={() => setShowDeleteModal(true)}
>
Delete edge function
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
</div>
<div className="!mt-8">
<FormHeader title="Delete Edge Function" description="" />
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>
Once your function is deleted, it can no longer be restored
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Make sure you have made a backup if you want to restore your edge function
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<ButtonTooltip
type="danger"
disabled={!canUpdateEdgeFunction}
loading={selectedFunction?.id === undefined}
onClick={() => setShowDeleteModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateEdgeFunction
? 'You need additional permissions to delete edge functions'
: undefined,
},
}}
>
Delete edge function
</ButtonTooltip>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<div className="flex-1 max-w-xl">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<dl className="grid grid-cols-1 xl:grid-cols-[12rem_1fr] gap-y-6">
<dt className="text-sm text-foreground-light">Slug</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.slug}</dd>
<dt className="text-sm text-foreground-light">Endpoint URL</dt>
<dd className="text-sm lg:text-left">
<Input
className="font-mono input-mono"
disabled
copy
size="small"
value={functionUrl}
/>
</dd>
<dt className="text-sm text-foreground-light">Region</dt>
<dd className="text-sm lg:text-left">All functions are deployed globally</dd>
<dt className="text-sm text-foreground-light">Created at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Last updated at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Deployments</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.version ?? 0}</dd>
<dt className="text-sm text-foreground-light">Import Maps</dt>
<dd className="text-sm lg:text-left">
<p>
Import maps are{' '}
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
</p>
<p className="text-foreground-light mt-1">
Import maps allow the use of bare specifiers in functions instead of explicit
import URLs
</p>
<div className="mt-4">
<Button
asChild
type="default"
size="tiny"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="https://supabase.com/docs/guides/functions/dependencies"
target="_blank"
rel="noreferrer"
>
More about import maps
</Link>
</Button>
</div>
</dd>
</dl>
</CardContent>
</Card>
</div>
</div>
@@ -0,0 +1,239 @@
'use client'
import { useState } from 'react'
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
Label_Shadcn_ as Label,
Select_Shadcn_ as Select,
SelectContent_Shadcn_ as SelectContent,
SelectItem_Shadcn_ as SelectItem,
SelectTrigger_Shadcn_ as SelectTrigger,
SelectValue_Shadcn_ as SelectValue,
TextArea_Shadcn_ as Textarea,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
Badge,
CodeBlock,
} from 'ui'
import { constructHeaders } from 'data/fetchers'
import { useParams } from 'common'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
import { useGetImpersonatedRole } from 'state/role-impersonation-state'
import {
RoleImpersonationPopover,
RoleImpersonationSelector,
} from 'components/interfaces/RoleImpersonationSelector'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
import { IS_PLATFORM } from 'lib/constants'
import { prettifyJSON } from 'lib/helpers'
type ResponseData = {
status: number
headers: Record<string, string>
body: string
}
interface EdgeFunctionTesterProps {
url: string
anonKey: string
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const
type HttpMethod = (typeof HTTP_METHODS)[number]
export default function EdgeFunctionTester({ url, anonKey }: EdgeFunctionTesterProps) {
const { ref: projectRef } = useParams()
const [method, setMethod] = useState<HttpMethod>('POST')
const [body, setBody] = useState('{ "name": "Functions" }')
const [response, setResponse] = useState<ResponseData | null>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
const getImpersonatedRole = useGetImpersonatedRole()
const { serviceKey } = getAPIKeys(settings)
const sendRequest = async () => {
try {
setIsLoading(true)
setError(null)
setResponse(null)
let requestBody: any
try {
requestBody = JSON.parse(body)
} catch (e) {
throw new Error('Invalid JSON in request body')
}
let testAuthorization: string | undefined
const role = getImpersonatedRole()
console.log('Role impersonation details:', {
role,
projectRef,
hasJwtSecret: !!config?.jwt_secret,
hasServiceKey: !!serviceKey?.api_key,
hasAccessToken: !!accessToken,
})
if (
projectRef !== undefined &&
config?.jwt_secret !== undefined &&
role !== undefined &&
role.type === 'postgrest'
) {
try {
console.log('Generating JWT for role:', {
roleDetails: role,
type: role.type,
})
const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
testAuthorization = 'Bearer ' + token
console.log('Successfully generated JWT token for role')
} catch (err: any) {
console.error('Failed to generate JWT:', {
error: err.message,
roleDetails: role,
})
// Don't throw, fall back to service key
console.log('Falling back to service key')
}
}
console.log('serviceKey', serviceKey)
const headers = await constructHeaders()
const res = await fetch('/api/edge-functions/test', {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
method,
body: requestBody,
headers: {
...(accessToken && {
Authorization: `Bearer ${accessToken}`,
}),
'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
'Content-Type': 'application/json',
},
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error?.message || 'Failed to test edge function')
}
setResponse(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred')
} finally {
setIsLoading(false)
}
}
return (
<>
<div className="space-y-4">
<div>
<Label className="text-foreground-light mb-2 block text-xs">Method</Label>
<Select
value={method}
onValueChange={(value) => setMethod(value as HttpMethod)}
disabled={isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{HTTP_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-foreground-light mb-2 block text-xs">Body</Label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Request body (JSON)"
rows={3}
disabled={isLoading}
className="font-mono text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<RoleImpersonationPopover />
<Button type="primary" onClick={sendRequest} loading={isLoading} disabled={isLoading}>
Send Request
</Button>
</div>
</div>
{error && (
<div className="mt-4 space-y-2">
<Label>Error</Label>
<p className="text-sm text-foreground-light">{error}</p>
</div>
)}
{response && (
<div className="mt-4 bg-surface-100 border-t -mx-4 -mb-4">
<Tabs defaultValue="body" className="w-full gap-0">
<TabsList className="gap-4 pt-2 px-4">
<div className="flex items-center gap-4 flex-1">
<TabsTrigger className="text-xs" value="body">
Body
</TabsTrigger>
<TabsTrigger className="text-xs" value="headers">
Headers
</TabsTrigger>
</div>
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
{response.status}
</Badge>
</TabsList>
<TabsContent value="body" className="mt-0">
<CodeBlock
language="json"
hideLineNumbers
className="rounded-md !border-none !px-4 !py-3 max-h-32"
value={prettifyJSON(response.body)}
/>
</TabsContent>
<TabsContent value="headers" className="mt-0">
<CodeBlock
language="json"
hideLineNumbers
className="rounded-md !border-none !px-4 !py-3 max-h-32"
value={prettifyJSON(JSON.stringify(response.headers, null, 2))}
/>
</TabsContent>
</Tabs>
</div>
)}
</>
)
}
@@ -50,6 +50,36 @@ Deno.serve(async (req: Request) => {
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
);
});`,
},
{
name: 'Supabase Database Access',
description: 'Example using Supabase client to query your database',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
)
const { data, error } = await supabase.from('countries').select('*')
if (error) {
throw error
}
return new Response(JSON.stringify({ data }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
} catch (err) {
return new Response(String(err?.message ?? err), { status: 500 })
}
})`,
},
{
name: 'Node Built-in API Example',
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, type PropsWithChildren } from 'react'
import { useEffect, type PropsWithChildren, useState } from 'react'
import { useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
@@ -11,19 +11,90 @@ import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { withAuth } from 'hooks/misc/withAuth'
import { Code } from 'lucide-react'
import { Code, Code2, Play, Send, Terminal, TestTubeDiagonal } from 'lucide-react'
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { Button, cn } from 'ui'
import { Button, cn, CodeBlock } from 'ui'
import { Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import FunctionsNav from '../../interfaces/Functions/FunctionsNav'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
import { PageLayout } from 'components/layouts/PageLayout'
import EdgeFunctionsLayout from '../EdgeFunctionsLayout/EdgeFunctionsLayout'
import EdgeFunctionTester from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTester'
interface FunctionsLayoutProps {
title?: string
}
const InvokePopover = ({
functionName,
url,
apiKey,
}: {
functionName: string
url: string
apiKey: string
}) => {
return (
<Popover_Shadcn_>
<PopoverTrigger_Shadcn_ asChild>
<Button type="default" icon={<Terminal size={16} />}>
Invoke
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="w-xl w-full max-w-[500px] space-y-4">
<div>
<h4 className="text-sm mb-2">Invoke via CLI</h4>
<CodeBlock
language="bash"
className="text-xs !mt-0"
value={`curl -L -X POST '${url}' \\
-H 'Authorization: Bearer ${apiKey}' \\
-H 'Content-Type: application/json' \\
--data '{"name":"Functions"}'`}
/>
</div>
<div>
<h4 className="text-sm mb-2">Invoke via supabase-js</h4>
<CodeBlock
language="js"
hideLineNumbers
className="text-xs !mt-0 leading-3"
value={`import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
const { data, error } = await supabase.functions.invoke('${functionName}', {
body: { name: 'Functions' },
})`}
/>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
const TestPopover = ({ url, apiKey }: { url: string; apiKey: string }) => {
const [isOpen, setIsOpen] = useState(false)
return (
<Popover_Shadcn_ open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="default" size="tiny" icon={<Send size={16} />}>
Test
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[500px]" align="end">
<div className="space-y-4">
<div>
<p className="text-sm mb-4">Send test requests to your edge function</p>
<EdgeFunctionTester url={url} anonKey={apiKey} />
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutProps>) => {
const router = useRouter()
const { functionSlug, ref } = useParams()
@@ -35,11 +106,17 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
error,
isError,
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
const { data: settings } = useProjectSettingsV2Query({ projectRef: ref })
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
const name = selectedFunction?.name || ''
const hasFunctions = (functions ?? []).length > 0
const { anonKey } = getAPIKeys(settings)
const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]'
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint ?? ''
const functionUrl = `${protocol}://${endpoint}/functions/v1/${functionSlug}`
useEffect(() => {
let cancel = false
@@ -92,42 +169,37 @@ const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutP
return (
<EdgeFunctionsLayout>
<ProjectLayout
isLoading={isLoading}
title={title || 'Edge Functions'}
product="Edge Functions"
<PageLayout
size="full"
title={functionSlug ? name : 'Edge Functions'}
subtitle={
functionSlug
? `https://${ref}.functions.supabase.co/${functionSlug}`
: 'Write and deploy code without having to manage servers'
}
breadcrumbs={breadcrumbItems}
navigationItems={navigationItems}
primaryActions={
<div className="flex items-center space-x-2">
{isNewAPIDocsEnabled && (
<APIDocsButton
section={
functionSlug !== undefined ? ['edge-functions', functionSlug] : ['edge-functions']
}
/>
)}
<DocsButton href="https://supabase.com/docs/guides/functions" />
{functionSlug && (
<>
<InvokePopover functionName={functionSlug} url={functionUrl} apiKey={apiKey} />
<TestPopover url={functionUrl} apiKey={apiKey} />
</>
)}
</div>
}
>
<PageLayout
size="full"
title={functionSlug ? name : 'Edge Functions'}
subtitle={
functionSlug
? `https://${ref}.functions.supabase.co/${functionSlug}`
: 'Write and deploy code without having to manage servers'
}
breadcrumbs={breadcrumbItems}
navigationItems={navigationItems}
primaryActions={
<div className="flex items-center space-x-2">
<Button asChild type="default">
<Link href={`/project/${ref}/settings/functions`}>Manage secrets</Link>
</Button>
{isNewAPIDocsEnabled && (
<APIDocsButton
section={
functionSlug !== undefined
? ['edge-functions', functionSlug]
: ['edge-functions']
}
/>
)}
<DocsButton href="https://supabase.com/docs/guides/functions" />
</div>
}
>
{children}
</PageLayout>
</ProjectLayout>
{children}
</PageLayout>
</EdgeFunctionsLayout>
)
}
@@ -1,4 +1,5 @@
import { useParams } from 'common'
import { ChevronLeft } from 'lucide-react'
import { Fragment, ReactNode } from 'react'
import { cn } from 'ui'
import {
@@ -47,11 +48,18 @@ export const PageHeader = ({
<Fragment key={item.label}>
<BreadcrumbItem>
{item.href ? (
<BreadcrumbLink href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
<BreadcrumbLink
className="flex items-center gap-2"
href={!!ref ? item.href.replace('[ref]', ref) : item.href}
>
{breadcrumbs.length === 1 && <ChevronLeft size={16} strokeWidth={1.5} />}
{item.label}
</BreadcrumbLink>
) : (
<BreadcrumbPageItem>{item.label}</BreadcrumbPageItem>
<BreadcrumbPageItem className="flex items-center gap-2">
{breadcrumbs.length === 1 && <ChevronLeft size={16} strokeWidth={1.5} />}
{item.label}
</BreadcrumbPageItem>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
@@ -72,7 +72,7 @@ const PageLayout = ({
const { ref } = useParams()
return (
<div className="w-full">
<div className="w-full min-h-full flex flex-col items-stretch [&>*]:w-full">
<div
className={cn(
'w-full mx-auto',
@@ -104,7 +104,10 @@ const PageLayout = ({
{item.href ? (
<Link
href={!!ref ? item.href.replace('[ref]', ref) : item.href}
className="inline-flex items-center gap-2"
className={cn(
'inline-flex items-center gap-2',
router.pathname === item.href && 'text-foreground'
)}
onClick={item.onClick}
>
{item.icon && <span>{item.icon}</span>}
@@ -121,9 +124,8 @@ const PageLayout = ({
</NavMenu>
)}
</div>
{/* Content section */}
<main>{children}</main>
{children}
</div>
)
}
+1
View File
@@ -13,6 +13,7 @@ const HOSTED_SUPPORTED_API_URLS = [
'/ai/onboarding/design',
'/ai/sql/complete',
'/ai/edge-function/complete',
'/edge-functions/test',
'/ai/sql/title',
'/ai/sql/debug',
'/ai/sql/cron',
@@ -0,0 +1,87 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { constructHeaders, handleError } from 'data/fetchers'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({
error: { message: `Method ${req.method} Not Allowed` },
})
}
try {
const { url, method, body, headers: customHeaders } = req.body
console.log('Edge function test request:', {
url,
method,
body,
customHeaders,
})
// Convert incoming headers to HeadersInit format
const incomingHeaders = Object.entries(req.headers).reduce(
(acc, [key, value]) => {
if (value) acc[key] = Array.isArray(value) ? value.join(', ') : value
return acc
},
{} as Record<string, string>
)
// Get auth headers from the request to forward them
const headers = await constructHeaders(incomingHeaders)
const requestHeaders = {
...headers,
'Content-Type': 'application/json',
...customHeaders,
}
// Use the test authorization header if provided, otherwise use the default authorization
if (customHeaders['x-test-authorization']) {
requestHeaders['Authorization'] = customHeaders['x-test-authorization']
console.log('Using test authorization header')
}
console.log('Forwarding request with headers:', requestHeaders)
const response = await fetch(url, {
method,
headers: requestHeaders,
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
})
if (!response.ok) {
const error = await response.json()
return handleError(error)
}
console.log('Edge function response status:', response.status)
const responseHeaders: Record<string, string> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
})
const responseBody = await response.text()
console.log('Edge function response:', {
status: response.status,
headers: responseHeaders,
body: responseBody,
})
return res.status(response.status).json({
status: response.status,
headers: responseHeaders,
body: responseBody,
})
} catch (error: any) {
console.error('Edge function test error:', {
message: error.message,
stack: error.stack,
cause: error.cause,
})
return res.status(500).json({
error: error.message || 'Failed to test edge function',
})
}
}
@@ -5,9 +5,9 @@ import { PageContainer } from 'components/layouts/PageLayout'
import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => (
<PageContainer className="py-8">
<div className="px-8">
<EdgeFunctionDetails />
</PageContainer>
</div>
)
PageLayout.getLayout = (page) => (
@@ -159,7 +159,7 @@ const PageLayout: NextPageWithLayout = () => {
return <NoPermission isFullPage resourceText="access this edge function" />
}
return (
<PageContainer size="full" className="py-8 px-8">
<div className="px-8 w-full pt-6">
<div className="space-y-6">
<div className="flex flex-row items-center gap-2">
<div className="flex items-center">
@@ -336,7 +336,7 @@ const PageLayout: NextPageWithLayout = () => {
</div>
</div>
</div>
</PageContainer>
</div>
)
}
@@ -20,15 +20,14 @@ export const LogPage: NextPageWithLayout = () => {
if (selectedFunction === undefined || isLoading) return null
return (
<PageContainer size="full" className="py-8 px-8">
<div className="flex-1">
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'fn_edge'}
filterOverride={{ function_id: selectedFunction.id }}
filterPanelClassName="px-0"
/>
</PageContainer>
</div>
)
}
@@ -17,15 +17,14 @@ export const LogPage: NextPageWithLayout = () => {
if (selectedFunction === undefined || isLoading) return null
return (
<PageContainer>
<div className="flex-1">
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'functions'}
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
filterPanelClassName="px-0"
/>
</PageContainer>
</div>
)
}
@@ -78,14 +78,18 @@ const FunctionsPage: NextPageWithLayout = () => {
return (
<PageLayout
size="medium"
size="large"
title="Edge Functions"
subtitle="Deploy edge functions to handle complex business logic"
primaryActions={deployButton}
secondaryActions={secondaryActions}
>
<PageContainer size="medium">
{isLoading && <GenericSkeletonLoader />}
<PageContainer size="large">
{isLoading && (
<div className="pt-8">
<GenericSkeletonLoader />
</div>
)}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
@@ -9,7 +9,7 @@ import { DocsButton } from 'components/ui/DocsButton'
const SecretsPage: NextPageWithLayout = () => {
return (
<PageContainer className="py-4">
<PageContainer className="py-4" size="large">
<EdgeFunctionSecrets />
</PageContainer>
)
@@ -20,6 +20,7 @@ SecretsPage.getLayout = (page) => {
<DefaultLayout>
<EdgeFunctionsLayout>
<PageLayout
size="large"
title="Edge Function Secrets"
subtitle="Manage the secrets for your project's edge functions"
>