Edge functions secrets updates (#31329)

* Rename secrets to env vars, add inline form

* Add multiple

* Fix adding multiple

* Change name back

* Sm update

* Add docs for secrets management

* Fix file paths

* Prettier

* Fix image paths

* Images

* clean up

* Fix form submit

* Minor fixes.

* Remove console logs.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Terry Sutton
2025-02-25 10:47:59 -03:30
committed by GitHub
parent 684a35cbd5
commit dc137679be
8 changed files with 301 additions and 233 deletions

View File

@@ -1,11 +1,13 @@
---
id: 'functions-secrets'
title: 'Managing Environment Variables'
title: 'Managing Secrets (Environment Variables)'
description: 'Managing secrets and environment variables.'
subtitle: 'Managing secrets and environment variables.'
---
It's common that you will need to use sensitive information or environment-specific variables inside your Edge Functions. You can access these using Deno's built-in handler
It's common that you will need to use environment variables or other sensitive information Edge Functions. You can manage secrets using the CLI or the Dashboard.
You can access these using Deno's built-in handler
```js
Deno.env.get('MY_SECRET_NAME')
@@ -57,7 +59,25 @@ When the function starts you should see the name “Yoda” output to the termin
## Production secrets
Let's create a `.env` for production. In this case we'll just use the same as our local secrets:
You will also need to set secrets for your production Edge Functions. You can do this via the Dashboard or using the CLI.
### Using the Dashboard
1. Visit [Edge Function Secrets Management](https://supabase.com/dashboard/project/_/settings/functions) page in your Dashboard.
2. Add the Key and Value for your secret and press Save.
3. Note that you can paste multiple secrets at a time.
<Image
alt="Edge Functions Secrets Management"
src={{
light: '/docs/img/edge-functions-secrets--light.jpg',
dark: '/docs/img/edge-functions-secrets.jpg',
}}
/>
### Using the CLI
Let's create a `.env` to help us deploy our secrets to production. In this case we'll just use the same as our local secrets:
```bash
cp ./supabase/.env.local ./supabase/.env
@@ -67,7 +87,7 @@ This creates a new file `./supabase/.env` for storing your production secrets.
<Admonition type="caution">
Never check your `.env` files into Git!
Never check your `.env` files into Git! You only use the `.env` file to help deploy your secrets to production. Don't commit it to your repository.
</Admonition>

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View File

@@ -37,7 +37,7 @@ const EdgeFunctionDetails = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showInstructions, setShowInstructions] = useState(false)
const [showInstructions, setShowInstructions] = useState(true)
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
@@ -220,7 +220,7 @@ const EdgeFunctionDetails = () => {
<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>
<h4>Deploy, invoke and manage secrets</h4>
</div>
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
{showInstructions ? (
@@ -231,11 +231,11 @@ const EdgeFunctionDetails = () => {
</div>
</div>
<h5 className="text-base">Deployment management</h5>
<h5 className="text-base">Deploy your function</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke </h5>
<h5 className="text-base">Invoke your function</h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Secrets management</h5>
<h5 className="text-base">Manage secrets</h5>
<CommandRender commands={secretCommands} />
</div>

View File

@@ -0,0 +1,191 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation'
import { Eye, EyeOff, MinusCircle } from 'lucide-react'
import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
FormLabel_Shadcn_,
FormMessage_Shadcn_,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
type SecretPair = {
name: string
value: string
}
const FormSchema = z.object({
secrets: z.array(
z.object({
name: z
.string()
.min(1, 'Please provide a name for your secret')
.refine((value) => !value.match(/^(SUPABASE_).*/), {
message: 'Name must not start with the SUPABASE_ prefix',
}),
value: z.string().min(1, 'Please provide a value for your secret'),
})
),
})
const defaultValues = {
secrets: [{ name: '', value: '' }],
}
const AddNewSecretForm = () => {
const { ref: projectRef } = useParams()
const [showSecretValue, setShowSecretValue] = useState(false)
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues,
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'secrets',
})
function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const text = e.clipboardData?.getData('text')
if (!text) return
const pairs: Array<SecretPair> = []
try {
const jsonData = JSON.parse(text)
Object.entries(jsonData).forEach(([key, value]) => {
pairs.push({ name: key, value: String(value) })
})
} catch {
// Try KEY=VALUE format (multiple lines)
const lines = text.split(/\n/)
lines.forEach((line) => {
const [key, ...valueParts] = line.split('=')
if (key && valueParts.length) {
pairs.push({
name: key.trim(),
value: valueParts.join('=').trim(),
})
}
})
}
if (pairs.length) {
// Replace all fields with new pairs
form.reset({ secrets: pairs })
}
}
const { mutate: createSecret, isLoading: isCreating } = useSecretsCreateMutation({
onSuccess: (_, variables) => {
toast.success(`Successfully created new secret "${variables.secrets[0].name}"`)
// RHF recommends using setTimeout/useEffect to reset the form
setTimeout(() => form.reset(), 0)
},
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
createSecret({ projectRef, secrets: data.secrets })
}
return (
<Panel>
<Panel.Content className="grid gap-4">
<h2 className="text-sm">Add new secrets</h2>
<Form_Shadcn_ {...form}>
<form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="grid grid-cols-[1fr_1fr_auto] gap-4 mb-4">
<FormField_Shadcn_
control={form.control}
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Key</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input
{...field}
placeholder="e.g. CLIENT_KEY"
onPaste={(e) => handlePaste(e.nativeEvent)}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<FormField_Shadcn_
control={form.control}
name={`secrets.${index}.value`}
render={({ field }) => (
<FormItem_Shadcn_ className="w-full relative">
<FormLabel_Shadcn_>Value</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input
{...field}
type={showSecretValue ? 'text' : 'password'}
actions={
<div className="mr-1">
<Button
type="text"
className="px-1"
icon={showSecretValue ? <EyeOff /> : <Eye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItem_Shadcn_>
)}
/>
<Button
type="default"
className="self-end h-9 flex"
icon={<MinusCircle />}
onClick={() => (fields.length > 1 ? remove(index) : form.reset(defaultValues))}
/>
</div>
))}
<Button
type="default"
onClick={() => {
const formValues = form.getValues('secrets')
const isEmptyForm = formValues.every((field) => !field.name && !field.value)
if (isEmptyForm) {
fields.forEach((_, index) => remove(index))
append({ name: '', value: '' })
} else {
append({ name: '', value: '' })
}
}}
>
Add another
</Button>
<div className="flex items-center gap-2 col-span-2 -mx-6 px-6 border-t pt-4 mt-4">
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
</Form_Shadcn_>
</Panel.Content>
</Panel>
)
}
export default AddNewSecretForm

View File

@@ -1,132 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useSecretsCreateMutation } from 'data/secrets/secrets-create-mutation'
import { Eye, EyeOff } from 'lucide-react'
import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Modal } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
interface AddNewSecretModalProps {
visible: boolean
onClose: () => void
}
const AddNewSecretModal = ({ visible, onClose }: AddNewSecretModalProps) => {
const { ref: projectRef } = useParams()
const submitRef = useRef<HTMLButtonElement>(null)
const [showSecretValue, setShowSecretValue] = useState(false)
const FormSchema = z.object({
name: z
.string()
.min(1, 'Please provide a name for your secret')
.refine((value) => !value.match(/^(SUPABASE_).*/), {
message: 'Name must not start with the SUPABASE_ prefix',
}),
value: z.string().min(1, 'Please provider a value for your secret'),
})
const defaultValues = { name: '', value: '' }
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues,
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
createSecret({ projectRef, secrets: [data] })
}
const { mutate: createSecret, isLoading: isCreating } = useSecretsCreateMutation({
onSuccess: (_, variables) => {
toast.success(`Successfully created new secret "${variables.secrets[0].name}"`)
onClose()
},
})
useEffect(() => {
if (visible) {
form.reset(defaultValues)
setShowSecretValue(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible])
return (
<Modal
size="small"
visible={visible}
onCancel={onClose}
header="Create a new secret"
alignFooter="right"
customFooter={
<div className="flex items-center gap-2">
<Button type="default" onClick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button
type="primary"
disabled={isCreating}
loading={isCreating}
onClick={() => submitRef?.current?.click()}
>
{isCreating ? 'Creating secret' : 'Create secret'}
</Button>
</div>
}
>
<Modal.Content>
<Form_Shadcn_ {...form}>
<form
id="create-secret-form"
className="w-full flex flex-col gap-y-2"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Secret name">
<FormControl_Shadcn_>
<Input {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="value"
render={({ field }) => (
<FormItemLayout label="Secret value">
<FormControl_Shadcn_>
<Input
{...field}
type={showSecretValue ? 'text' : 'password'}
actions={
<div className="mr-1">
<Button
type="default"
className="px-1"
icon={showSecretValue ? <EyeOff /> : <Eye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<button className="hidden" type="submit" ref={submitRef} />
</form>
</Form_Shadcn_>
</Modal.Content>
</Modal>
)
}
export default AddNewSecretModal

View File

@@ -6,23 +6,20 @@ import { toast } from 'sonner'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useSecretsDeleteMutation } from 'data/secrets/secrets-delete-mutation'
import { ProjectSecret, useSecretsQuery } from 'data/secrets/secrets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Badge } from 'ui'
import { Badge, Separator } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import AddNewSecretModal from './AddNewSecretModal'
import AddNewSecretForm from './AddNewSecretForm'
import EdgeFunctionSecret from './EdgeFunctionSecret'
const EdgeFunctionSecrets = () => {
const { ref: projectRef } = useParams()
const [searchString, setSearchString] = useState('')
const [showCreateSecret, setShowCreateSecret] = useState(false)
const [selectedSecret, setSelectedSecret] = useState<ProjectSecret>()
const canReadSecrets = useCheckPermissions(PermissionAction.SECRETS_READ, '*')
@@ -48,92 +45,78 @@ const EdgeFunctionSecrets = () => {
return (
<>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve project secrets" />}
{isSuccess && (
<div className="space-y-4">
{!canReadSecrets ? (
<NoPermission resourceText="view this project's edge function secrets" />
) : (
<>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2">
<Input
size="small"
className="w-full md:w-80"
placeholder="Search for a secret"
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
icon={<Search size={14} />}
/>
<div className="flex items-center space-x-2">
<DocsButton href="https://supabase.com/docs/guides/functions/secrets" />
<ButtonTooltip
disabled={!canUpdateSecrets}
onClick={() => setShowCreateSecret(true)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateSecrets
? 'You need additional permissions to update edge function secrets'
: undefined,
},
}}
>
Add new secret
</ButtonTooltip>
{isSuccess && canUpdateSecrets && (
<>
<div className="grid gap-5">
<AddNewSecretForm />
<Separator />
</div>
<div className="space-y-4 mt-4">
{!canReadSecrets ? (
<NoPermission resourceText="view this project's edge function secrets" />
) : (
<>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-2">
<Input
size="small"
className="w-full md:w-80"
placeholder="Search for a secret"
value={searchString}
onChange={(e: any) => setSearchString(e.target.value)}
icon={<Search size={14} />}
/>
</div>
</div>
<Table
head={[
<Table.th key="secret-name">Name</Table.th>,
<Table.th key="secret-value" className="flex items-center gap-x-2">
Digest{' '}
<Badge color="scale" className="font-mono">
SHA256
</Badge>
</Table.th>,
<Table.th key="actions" />,
]}
body={
secrets.length > 0 ? (
secrets.map((secret) => (
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchString}" did not return any results
</p>
</Table.td>
</Table.tr>
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No secrets created</p>
<p className="text-sm text-foreground-light">
There are no secrets associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
</>
)}
</div>
<Table
head={[
<Table.th key="secret-name">Name</Table.th>,
<Table.th key="secret-value" className="flex items-center gap-x-2">
Digest{' '}
<Badge color="scale" className="font-mono">
SHA256
</Badge>
</Table.th>,
<Table.th key="actions" />,
]}
body={
secrets.length > 0 ? (
secrets.map((secret) => (
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchString}" did not return any results
</p>
</Table.td>
</Table.tr>
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No secrets created</p>
<p className="text-sm text-foreground-light">
There are no secrets associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
</>
)}
</div>
</>
)}
<AddNewSecretModal visible={showCreateSecret} onClose={() => setShowCreateSecret(false)} />
<ConfirmationModal
variant="warning"
variant="destructive"
loading={isDeleting}
visible={selectedSecret !== undefined}
confirmLabel="Delete secret"
@@ -147,8 +130,8 @@ const EdgeFunctionSecrets = () => {
}}
>
<p className="text-sm">
Before removing this secret, do ensure that none of your edge functions are currently
actively using this secret. This action cannot be undone.
Before removing this secret, ensure none of your Edge Functions are actively using it.
This action cannot be undone.
</p>
</ConfirmationModal>
</>

View File

@@ -8,16 +8,22 @@ import {
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
import { DocsButton } from 'components/ui/DocsButton'
const PageLayout: NextPageWithLayout = () => {
return (
<>
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Edge Function Secrets Management</ScaffoldTitle>
<ScaffoldDescription>
Manage the secrets for your project's edge functions
</ScaffoldDescription>
<ScaffoldHeader className="flex flex-row justify-between">
<div>
<ScaffoldTitle>Edge Function Secrets</ScaffoldTitle>
<ScaffoldDescription>
Manage the secrets (environment variables) for your project's Edge Functions
</ScaffoldDescription>
</div>
<div className="flex items-center space-x-2">
<DocsButton href="https://supabase.com/docs/guides/functions/secrets" />
</div>
</ScaffoldHeader>
</ScaffoldContainer>
<ScaffoldContainer bottomPadding>