EdgeFunctionDetails.tsx388 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { IS_PLATFORM, useParams } from 'common' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import { useEffect, useMemo, useState } from 'react' |
| 6 | import { SubmitHandler, useForm } from 'react-hook-form' |
| 7 | import { toast } from 'sonner' |
| 8 | import { |
| 9 | Alert, |
| 10 | AlertDescription, |
| 11 | AlertTitle, |
| 12 | Button, |
| 13 | Card, |
| 14 | CardContent, |
| 15 | CardFooter, |
| 16 | cn, |
| 17 | copyToClipboard, |
| 18 | CriticalIcon, |
| 19 | Form, |
| 20 | FormControl, |
| 21 | FormField, |
| 22 | Switch, |
| 23 | Tabs_Shadcn_ as Tabs, |
| 24 | TabsContent_Shadcn_ as TabsContent, |
| 25 | TabsList_Shadcn_ as TabsList, |
| 26 | TabsTrigger_Shadcn_ as TabsTrigger, |
| 27 | } from 'ui' |
| 28 | import { CodeBlock } from 'ui-patterns/CodeBlock' |
| 29 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 30 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 31 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 32 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 33 | import { |
| 34 | PageSection, |
| 35 | PageSectionContent, |
| 36 | PageSectionMeta, |
| 37 | PageSectionSummary, |
| 38 | PageSectionTitle, |
| 39 | } from 'ui-patterns/PageSection' |
| 40 | import z from 'zod' |
| 41 | |
| 42 | import CommandRender from '../CommandRender' |
| 43 | import { INVOCATION_TABS } from './EdgeFunctionDetails.constants' |
| 44 | import { generateCLICommands } from './EdgeFunctionDetails.utils' |
| 45 | import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query' |
| 46 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 47 | import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' |
| 48 | import { useEdgeFunctionDeleteMutation } from '@/data/edge-functions/edge-functions-delete-mutation' |
| 49 | import { useEdgeFunctionUpdateMutation } from '@/data/edge-functions/edge-functions-update-mutation' |
| 50 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 51 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 52 | |
| 53 | const FormSchema = z.object({ |
| 54 | name: z.string().min(0, 'Name is required'), |
| 55 | verify_jwt: z.boolean(), |
| 56 | }) |
| 57 | |
| 58 | export const EdgeFunctionDetails = () => { |
| 59 | const router = useRouter() |
| 60 | const { ref: projectRef, functionSlug } = useParams() |
| 61 | |
| 62 | const showAllEdgeFunctionInvocationExamples = useIsFeatureEnabled( |
| 63 | 'edge_functions:show_all_edge_function_invocation_examples' |
| 64 | ) |
| 65 | const invocationTabs = useMemo(() => { |
| 66 | if (showAllEdgeFunctionInvocationExamples) return INVOCATION_TABS |
| 67 | return INVOCATION_TABS.filter((tab) => tab.id === 'curl' || tab.id === 'briven-js') |
| 68 | }, [showAllEdgeFunctionInvocationExamples]) |
| 69 | |
| 70 | const [showKey, setShowKey] = useState(false) |
| 71 | const [selectedTab, setSelectedTab] = useState(invocationTabs[0].id) |
| 72 | const [showDeleteModal, setShowDeleteModal] = useState(false) |
| 73 | |
| 74 | const { can: canUpdateEdgeFunctionPermission } = useAsyncCheckPermissions( |
| 75 | PermissionAction.FUNCTIONS_WRITE, |
| 76 | '*' |
| 77 | ) |
| 78 | |
| 79 | const canUpdateEdgeFunction = IS_PLATFORM && canUpdateEdgeFunctionPermission |
| 80 | |
| 81 | const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') |
| 82 | const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys }) |
| 83 | |
| 84 | const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug }) |
| 85 | |
| 86 | const { data: endpoint } = useProjectApiUrl({ projectRef }) |
| 87 | const functionUrl = `${endpoint}/functions/v1/${selectedFunction?.slug}` |
| 88 | |
| 89 | const { mutate: updateEdgeFunction, isPending: isUpdating } = useEdgeFunctionUpdateMutation() |
| 90 | const { mutate: deleteEdgeFunction, isPending: isDeleting } = useEdgeFunctionDeleteMutation({ |
| 91 | onSuccess: () => { |
| 92 | toast.success(`Successfully deleted "${selectedFunction?.name}"`) |
| 93 | router.push(`/project/${projectRef}/functions`) |
| 94 | }, |
| 95 | }) |
| 96 | |
| 97 | const form = useForm({ |
| 98 | resolver: zodResolver(FormSchema as any), |
| 99 | defaultValues: { name: '', verify_jwt: false }, |
| 100 | }) |
| 101 | |
| 102 | const { anonKey, publishableKey } = getKeys(apiKeys) |
| 103 | const apiKey = publishableKey?.api_key ?? anonKey?.api_key ?? '[YOUR ANON KEY]' |
| 104 | |
| 105 | const { managementCommands } = generateCLICommands({ |
| 106 | selectedFunction, |
| 107 | functionUrl, |
| 108 | anonKey: apiKey, |
| 109 | }) |
| 110 | |
| 111 | const onUpdateFunction: SubmitHandler<z.infer<typeof FormSchema>> = async (values: any) => { |
| 112 | if (!projectRef) return console.error('Project ref is required') |
| 113 | if (selectedFunction === undefined) return console.error('No edge function selected') |
| 114 | |
| 115 | updateEdgeFunction( |
| 116 | { |
| 117 | projectRef, |
| 118 | slug: selectedFunction.slug, |
| 119 | payload: values, |
| 120 | }, |
| 121 | { |
| 122 | onSuccess: () => { |
| 123 | toast.success(`Successfully updated edge function`) |
| 124 | }, |
| 125 | } |
| 126 | ) |
| 127 | } |
| 128 | |
| 129 | const onConfirmDelete = async () => { |
| 130 | if (!projectRef) return console.error('Project ref is required') |
| 131 | if (selectedFunction === undefined) return console.error('No edge function selected') |
| 132 | deleteEdgeFunction({ projectRef, slug: selectedFunction.slug }) |
| 133 | } |
| 134 | |
| 135 | useEffect(() => { |
| 136 | if (selectedFunction) { |
| 137 | form.reset({ |
| 138 | name: selectedFunction.name, |
| 139 | verify_jwt: selectedFunction.verify_jwt, |
| 140 | }) |
| 141 | } |
| 142 | }, [selectedFunction]) |
| 143 | |
| 144 | return ( |
| 145 | <PageContainer size="small"> |
| 146 | <PageSection> |
| 147 | <PageSectionMeta> |
| 148 | <PageSectionSummary> |
| 149 | <PageSectionTitle>Function configuration</PageSectionTitle> |
| 150 | </PageSectionSummary> |
| 151 | </PageSectionMeta> |
| 152 | <PageSectionContent> |
| 153 | <Form {...form}> |
| 154 | <form onSubmit={form.handleSubmit(onUpdateFunction)}> |
| 155 | <Card> |
| 156 | <CardContent> |
| 157 | <FormField |
| 158 | control={form.control} |
| 159 | name="name" |
| 160 | render={({ field }) => ( |
| 161 | <FormItemLayout |
| 162 | label="Name" |
| 163 | layout="flex-row-reverse" |
| 164 | description="Your slug and endpoint URL will remain the same" |
| 165 | > |
| 166 | <FormControl> |
| 167 | <Input {...field} className="w-64" disabled={!canUpdateEdgeFunction} /> |
| 168 | </FormControl> |
| 169 | </FormItemLayout> |
| 170 | )} |
| 171 | /> |
| 172 | </CardContent> |
| 173 | {IS_PLATFORM && ( |
| 174 | <> |
| 175 | <CardContent> |
| 176 | <FormField |
| 177 | control={form.control} |
| 178 | name="verify_jwt" |
| 179 | render={({ field }) => ( |
| 180 | <FormItemLayout |
| 181 | label="Verify JWT with legacy secret" |
| 182 | layout="flex-row-reverse" |
| 183 | description={ |
| 184 | <> |
| 185 | <p className="mb-2"> |
| 186 | Requires a JWT signed{' '} |
| 187 | <em className="text-foreground not-italic"> |
| 188 | only by the legacy secret |
| 189 | </em>{' '} |
| 190 | in the{' '} |
| 191 | <code className="text-code-inline break-keep!"> |
| 192 | Authorization |
| 193 | </code>{' '} |
| 194 | header. The <code className="text-code-inline">anon</code> key |
| 195 | satisfies this. |
| 196 | </p> |
| 197 | <p> |
| 198 | Recommended: OFF with JWT and custom auth logic in your function |
| 199 | code. |
| 200 | </p> |
| 201 | </> |
| 202 | } |
| 203 | > |
| 204 | <FormControl> |
| 205 | <Switch |
| 206 | checked={field.value} |
| 207 | onCheckedChange={field.onChange} |
| 208 | disabled={!canUpdateEdgeFunction} |
| 209 | /> |
| 210 | </FormControl> |
| 211 | </FormItemLayout> |
| 212 | )} |
| 213 | /> |
| 214 | </CardContent> |
| 215 | |
| 216 | <CardFooter className="flex justify-end space-x-2"> |
| 217 | {form.formState.isDirty && ( |
| 218 | <Button type="default" onClick={() => form.reset()}> |
| 219 | Cancel |
| 220 | </Button> |
| 221 | )} |
| 222 | <Button |
| 223 | type="primary" |
| 224 | htmlType="submit" |
| 225 | loading={isUpdating} |
| 226 | disabled={!canUpdateEdgeFunction || !form.formState.isDirty} |
| 227 | > |
| 228 | Save changes |
| 229 | </Button> |
| 230 | </CardFooter> |
| 231 | </> |
| 232 | )} |
| 233 | </Card> |
| 234 | </form> |
| 235 | </Form> |
| 236 | </PageSectionContent> |
| 237 | </PageSection> |
| 238 | |
| 239 | <PageSection> |
| 240 | <PageSectionMeta> |
| 241 | <PageSectionSummary> |
| 242 | <PageSectionTitle>Invoke function</PageSectionTitle> |
| 243 | </PageSectionSummary> |
| 244 | </PageSectionMeta> |
| 245 | <PageSectionContent> |
| 246 | <Card> |
| 247 | <CardContent className="px-0"> |
| 248 | <Tabs |
| 249 | className="w-full" |
| 250 | defaultValue="curl" |
| 251 | value={selectedTab} |
| 252 | onValueChange={setSelectedTab} |
| 253 | > |
| 254 | <TabsList className="flex flex-wrap gap-4 px-6"> |
| 255 | {invocationTabs.map((tab) => ( |
| 256 | <TabsTrigger key={tab.id} value={tab.id}> |
| 257 | {tab.label} |
| 258 | </TabsTrigger> |
| 259 | ))} |
| 260 | {selectedTab === 'curl' && ( |
| 261 | <Button |
| 262 | type="default" |
| 263 | className="ml-auto -translate-y-2 translate-x-3" |
| 264 | onClick={() => setShowKey(!showKey)} |
| 265 | > |
| 266 | {showKey ? 'Hide' : 'Show'} anon key |
| 267 | </Button> |
| 268 | )} |
| 269 | </TabsList> |
| 270 | {invocationTabs.map((tab) => { |
| 271 | const code = tab.code({ |
| 272 | showKey, |
| 273 | functionUrl, |
| 274 | functionName: selectedFunction?.name ?? '', |
| 275 | apiKey, |
| 276 | }) |
| 277 | |
| 278 | return ( |
| 279 | <TabsContent key={tab.id} value={tab.id}> |
| 280 | <CodeBlock |
| 281 | value={code} |
| 282 | wrapperClassName="[&>div]:top-0 [&>div]:right-3 px-6" |
| 283 | className={cn( |
| 284 | 'p-0 text-xs mt-0! border-none ', |
| 285 | showKey ? '[&>code]:break-all' : '[&>code]:wrap-break-word' |
| 286 | )} |
| 287 | language={tab.language} |
| 288 | wrapLines={false} |
| 289 | hideLineNumbers={tab.hideLineNumbers} |
| 290 | handleCopy={() => { |
| 291 | copyToClipboard( |
| 292 | tab.code({ |
| 293 | showKey: true, |
| 294 | functionUrl, |
| 295 | functionName: selectedFunction?.name ?? '', |
| 296 | apiKey, |
| 297 | }) |
| 298 | ) |
| 299 | }} |
| 300 | /> |
| 301 | </TabsContent> |
| 302 | ) |
| 303 | })} |
| 304 | </Tabs> |
| 305 | </CardContent> |
| 306 | </Card> |
| 307 | </PageSectionContent> |
| 308 | </PageSection> |
| 309 | |
| 310 | {IS_PLATFORM && ( |
| 311 | <> |
| 312 | <PageSection> |
| 313 | <PageSectionMeta> |
| 314 | <PageSectionSummary> |
| 315 | <PageSectionTitle>Develop locally</PageSectionTitle> |
| 316 | </PageSectionSummary> |
| 317 | </PageSectionMeta> |
| 318 | <PageSectionContent> |
| 319 | <div className="rounded-sm border bg-surface-100 px-6 py-4 drop-shadow-xs"> |
| 320 | <div className="space-y-6"> |
| 321 | <CommandRender |
| 322 | commands={[ |
| 323 | { |
| 324 | command: `briven functions download ${selectedFunction?.slug}`, |
| 325 | description: 'Download the function to your local machine', |
| 326 | jsx: () => ( |
| 327 | <> |
| 328 | <span className="text-brand">briven</span> functions download{' '} |
| 329 | {selectedFunction?.slug} |
| 330 | </> |
| 331 | ), |
| 332 | comment: '1. Download the function', |
| 333 | }, |
| 334 | ]} |
| 335 | /> |
| 336 | <CommandRender commands={[managementCommands[0]]} /> |
| 337 | <CommandRender commands={[managementCommands[1]]} /> |
| 338 | </div> |
| 339 | </div> |
| 340 | </PageSectionContent> |
| 341 | </PageSection> |
| 342 | <PageSection> |
| 343 | <PageSectionMeta> |
| 344 | <PageSectionSummary> |
| 345 | <PageSectionTitle>Delete function</PageSectionTitle> |
| 346 | </PageSectionSummary> |
| 347 | </PageSectionMeta> |
| 348 | <PageSectionContent> |
| 349 | <Alert variant="destructive"> |
| 350 | <CriticalIcon /> |
| 351 | <AlertTitle>Once your function is deleted, it can no longer be restored</AlertTitle> |
| 352 | <AlertDescription> |
| 353 | Make sure you have made a backup if you want to restore your edge function |
| 354 | </AlertDescription> |
| 355 | <AlertDescription className="mt-3"> |
| 356 | <Button |
| 357 | type="danger" |
| 358 | disabled={!canUpdateEdgeFunction} |
| 359 | loading={selectedFunction?.id === undefined} |
| 360 | onClick={() => setShowDeleteModal(true)} |
| 361 | > |
| 362 | Delete edge function |
| 363 | </Button> |
| 364 | </AlertDescription> |
| 365 | </Alert> |
| 366 | </PageSectionContent> |
| 367 | </PageSection> |
| 368 | <ConfirmationModal |
| 369 | visible={showDeleteModal} |
| 370 | loading={isDeleting} |
| 371 | variant="destructive" |
| 372 | confirmLabel="Delete" |
| 373 | confirmLabelLoading="Deleting" |
| 374 | title={`Confirm to delete ${selectedFunction?.name}`} |
| 375 | onCancel={() => setShowDeleteModal(false)} |
| 376 | onConfirm={onConfirmDelete} |
| 377 | alert={{ |
| 378 | base: { variant: 'destructive' }, |
| 379 | title: 'This action cannot be undone', |
| 380 | description: |
| 381 | 'Ensure that you have made a backup if you want to restore your edge function', |
| 382 | }} |
| 383 | /> |
| 384 | </> |
| 385 | )} |
| 386 | </PageContainer> |
| 387 | ) |
| 388 | } |