diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 11f6df3e22c..c46ee161d1b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -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 ( <> -
-
- {({ 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 ( - <> - - -
- } - > - Function Details}> - - - - - - - - - - - Function Configuration}> - - +
+
+ + + + + + {(field) => ( + + )} + + + + + {(field) => ( + -
-
-

- Import maps are{' '} - - {hasImportMap ? 'used' : 'not used'} - {' '} - for this function -

-
-

- Import maps allow the use of bare specifiers in functions instead of - explicit import URLs -

-
- -
-
- - - - - ) - }} - + )} +
+
+ + {form.formState.isDirty && ( + + )} + + +
+ +
-
-
-
-
- +
+
+
+
+ +
+

Command line access

+
+
setShowInstructions(!showInstructions)} + > + {showInstructions ? ( + + ) : ( + + )}
-

Command line access

-
-
setShowInstructions(!showInstructions)}> - {showInstructions ? ( - - ) : ( - - )}
+ +
Deployment management
+ +
Invoke
+ +
Secrets management
+
-
Deployment management
- -
Invoke
- -
Secrets management
- +
+ + + + Once your function is deleted, it can no longer be restored + + + Make sure you have made a backup if you want to restore your edge function + + + + + +
-
- - - - - Once your function is deleted, it can no longer be restored - - - Make sure you have made a backup if you want to restore your edge function - - - setShowDeleteModal(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canUpdateEdgeFunction - ? 'You need additional permissions to delete edge functions' - : undefined, - }, - }} - > - Delete edge function - - - +
+ + + Details + + +
+
Slug
+
{selectedFunction?.slug}
+ +
Endpoint URL
+
+ +
+ +
Region
+
All functions are deployed globally
+ +
Created at
+
+ {dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')} +
+ +
Last updated at
+
+ {dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')} +
+ +
Deployments
+
{selectedFunction?.version ?? 0}
+ +
Import Maps
+
+

+ Import maps are{' '} + + {hasImportMap ? 'used' : 'not used'} + {' '} + for this function +

+

+ Import maps allow the use of bare specifiers in functions instead of explicit + import URLs +

+
+ +
+
+
+
+
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTester.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTester.tsx new file mode 100644 index 00000000000..b7c729a4456 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTester.tsx @@ -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 + 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('POST') + const [body, setBody] = useState('{ "name": "Functions" }') + const [response, setResponse] = useState(null) + const [error, setError] = useState(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 ( + <> +
+
+ + +
+ +
+ +