chore: fix secrets editor for functions to be text area/ support newlines (#46754)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Update to support text area for functions

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

* **New Features**
* Secret inputs now accept and preserve multi-line values and
auto-resize to fit content.
* Secret values can be masked/unmasked via a show/hide toggle with
tooltip; masking uses styled concealment.
* Per-secret controls refined: clearer row layout, dedicated remove
icon, and add/save controls moved to the card footer.

* **Tests**
* Added tests validating multi-line secret entry and that submitted
payloads include embedded newlines.
* Updated tests to assert masking/unmasking behavior via visual security
styling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: kemal <hello@kemal.earth>
This commit is contained in:
Ali Waseemandkemal authored and GitHub committed 2026-06-12 08:11:36 -06:00
1 parent e92f13f11a
commit 47dbbddc91
6 files changed
+268 -77

No files matched your search

@@ -0,0 +1,69 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { HttpResponse } from 'msw'
import { describe, expect, test } from 'vitest'
import { AddNewSecretForm } from './AddNewSecretForm'
import type { ProjectSecret } from '@/data/secrets/secrets-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
const multilineValue = '-----BEGIN CERTIFICATE-----\nline2\nline3\n-----END CERTIFICATE-----'
describe('AddNewSecretForm', () => {
test('renders the value field as a textarea so multiline pastes are preserved', () => {
addAPIMock({
method: 'get',
path: '/v1/projects/:ref/secrets',
response: () => HttpResponse.json<ProjectSecret[]>([]),
})
customRender(<AddNewSecretForm />)
const nameInput = screen.getByPlaceholderText('e.g. CLIENT_KEY')
expect(nameInput.tagName).toBe('INPUT')
const textareas = screen.getAllByRole('textbox')
const valueTextarea = textareas.find((el) => el.tagName === 'TEXTAREA')
expect(valueTextarea).toBeDefined()
})
test('submits a multiline value with newlines intact', async () => {
const requests: Array<{ ref: string | undefined; body: unknown }> = []
addAPIMock({
method: 'post',
path: '/v1/projects/:ref/secrets',
response: async ({ request, params }) => {
requests.push({ ref: params.ref as string | undefined, body: await request.json() })
return HttpResponse.json({}, { status: 201 })
},
})
addAPIMock({
method: 'get',
path: '/v1/projects/:ref/secrets',
response: () => HttpResponse.json<ProjectSecret[]>([]),
})
customRender(<AddNewSecretForm />)
const nameInput = screen.getByPlaceholderText('e.g. CLIENT_KEY')
const saveButton = screen.getByRole('button', { name: 'Save' })
await userEvent.type(nameInput, 'SSL_CERT')
const textareas = screen.getAllByRole('textbox')
const valueTextarea = textareas.find((el) => el.tagName === 'TEXTAREA')!
await userEvent.type(valueTextarea, multilineValue)
fireEvent.click(saveButton)
await waitFor(() => {
expect(requests).toEqual([
{
ref: 'default',
body: [{ name: 'SSL_CERT', value: multilineValue }],
},
])
})
})
})
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { Eye, EyeOff, MinusCircle } from 'lucide-react'
import { Eye, EyeOff, Trash } from 'lucide-react'
import { useState } from 'react'
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -11,14 +11,17 @@ import {
CardFooter,
CardHeader,
CardTitle,
cn,
ExpandingTextArea,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
import { DuplicateSecretWarningModal } from './DuplicateSecretWarningModal'
@@ -206,81 +209,114 @@ export const AddNewSecretForm = () => {
</CardHeader>
<CardContent>
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="grid grid-cols-[1fr_1fr_auto] gap-4 mb-4">
<div
key={fieldItem.id}
className={cn(
'flex flex-col gap-4 last:mb-0 mb-4',
index > 0 &&
'border-t border-default pt-4 -mx-(--card-padding-x) px-(--card-padding-x)'
)}
>
<FormField
control={form.control}
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g. CLIENT_KEY"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
onPaste={(e) => handlePaste(e.nativeEvent)}
<FormItemLayout
label="Name"
layout="flex-row-reverse"
description="A unique name for your secret."
>
<div className="flex w-full items-center gap-2">
<FormControl className="flex-1">
<Input
{...field}
className="w-full font-mono"
containerClassName="w-full"
placeholder="e.g. CLIENT_KEY"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
onPaste={(e) => handlePaste(e.nativeEvent)}
/>
</FormControl>
<Button
type="default"
className="w-[34px] h-[34px] shrink-0 p-0"
aria-label="Remove secret"
icon={<Trash size={12} />}
disabled={fields.length <= 1}
onClick={() => handleRemoveSecret(fieldItem.id, index)}
/>
</FormControl>
<FormMessage />
</FormItem>
</div>
</FormItemLayout>
)}
/>
<FormField
control={form.control}
name={`secrets.${index}.value`}
render={({ field }) => (
<FormItem className="w-full relative">
<FormLabel>Value</FormLabel>
<FormItemLayout
label="Value"
layout="flex-row-reverse"
description="Supports multi-line values such as PEM keys, JSON, or functions."
>
<FormControl>
<Input
{...field}
type={isSecretVisible(fieldItem.id) ? 'text' : 'password'}
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
actions={
<div className="mr-1">
<div className="relative w-full">
<ExpandingTextArea
{...field}
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
className="font-mono max-h-[320px] pr-10"
style={
{
WebkitTextSecurity: isSecretVisible(fieldItem.id)
? undefined
: 'disc',
} as React.CSSProperties
}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
className="px-1"
className="absolute right-3 top-2 px-1"
aria-label={
isSecretVisible(fieldItem.id)
? 'Hide secret value'
: 'Show secret value'
}
icon={isSecretVisible(fieldItem.id) ? <EyeOff /> : <Eye />}
onClick={() => handleToggleSecretVisibility(fieldItem.id)}
/>
</div>
}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
{isSecretVisible(fieldItem.id) ? 'Hide value' : 'Show value'}
</TooltipContent>
</Tooltip>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormItemLayout>
)}
/>
<Button
type="default"
className="h-[34px] mt-6"
icon={<MinusCircle />}
disabled={fields.length <= 1}
onClick={() => handleRemoveSecret(fieldItem.id, index)}
/>
</div>
))}
<Button type="default" onClick={handleAddAnotherSecret}>
Add another
</Button>
</CardContent>
<CardFooter className="justify-between space-x-2">
<p className="text-sm text-foreground-muted">
Insert or update multiple secrets at once by pasting key-value pairs
</p>
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
</Button>
<div className="flex items-center space-x-2">
<Button type="default" onClick={handleAddAnotherSecret}>
Add another
</Button>
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
</Button>
</div>
</CardFooter>
</Card>
</form>
@@ -17,8 +17,8 @@ import {
SheetHeader,
SheetSection,
SheetTitle,
Textarea,
} from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
@@ -117,25 +117,42 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro
description="Secrets can’t be retrieved once saved. Enter a new value to overwrite the existing value."
>
<FormControl>
<PasswordInput
{...field}
type={showSecretValue ? 'text' : 'password'}
placeholder="my-secret-value"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
actions={
<div className="mr-1">
<Button
type="text"
className="px-1"
icon={showSecretValue ? <EyeOff /> : <Eye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
}
/>
<div className="relative">
<Textarea
{...field}
rows={1}
ref={(el) => {
field.ref(el)
if (el) {
el.style.height = 'auto'
el.style.height = Math.max(40, el.scrollHeight) + 'px'
}
}}
placeholder="my-secret-value"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
className="min-h-0 resize-none"
style={
{
WebkitTextSecurity: showSecretValue ? undefined : 'disc',
} as React.CSSProperties
}
onChange={(e) => {
field.onChange(e)
e.currentTarget.style.height = 'auto'
e.currentTarget.style.height =
Math.max(40, e.currentTarget.scrollHeight) + 'px'
}}
/>
<Button
type="text"
className="absolute right-1 top-1 px-1"
icon={showSecretValue ? <EyeOff /> : <Eye />}
onClick={() => setShowSecretValue(!showSecretValue)}
/>
</div>
</FormControl>
</FormItemLayout>
)}
@@ -1,5 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { Eye, EyeOff } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
@@ -15,8 +17,11 @@ import {
FormControl,
FormField,
Input,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
@@ -37,6 +42,8 @@ export const AddNewSecretModal = () => {
const { mutateAsync: addSecret } = useVaultSecretCreateMutation()
const [isSecretVisible, setIsSecretVisible] = useState(false)
const [showAddSecretModal, setShowAddSecretModal] = useQueryState(
'new',
parseAsBoolean.withDefault(false)
@@ -44,6 +51,7 @@ export const AddNewSecretModal = () => {
const handleClose = () => {
setShowAddSecretModal(null)
setIsSecretVisible(false)
form.reset()
}
@@ -116,7 +124,47 @@ export const AddNewSecretModal = () => {
render={({ field }) => (
<FormItemLayout layout="vertical" label="Secret value">
<FormControl className="col-span-6">
<PasswordInput reveal copy {...field} />
<div className="relative">
<Textarea
{...field}
rows={1}
ref={(el) => {
field.ref(el)
if (el) {
el.style.height = 'auto'
el.style.height = Math.max(40, el.scrollHeight) + 'px'
}
}}
className="min-h-0 resize-none"
style={
{
WebkitTextSecurity: isSecretVisible ? undefined : 'disc',
} as React.CSSProperties
}
onChange={(e) => {
field.onChange(e)
e.currentTarget.style.height = 'auto'
e.currentTarget.style.height =
Math.max(40, e.currentTarget.scrollHeight) + 'px'
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
className="absolute right-1 top-1 px-1"
aria-label={
isSecretVisible ? 'Hide secret value' : 'Show secret value'
}
icon={isSecretVisible ? <EyeOff /> : <Eye />}
onClick={() => setIsSecretVisible((prev) => !prev)}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
{isSecretVisible ? 'Hide value' : 'Show value'}
</TooltipContent>
</Tooltip>
</div>
</FormControl>
</FormItemLayout>
)}
@@ -17,6 +17,7 @@ import {
FormControl,
FormField,
Input,
Textarea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
@@ -174,11 +175,30 @@ export const EditSecretModal = () => {
<FormItemLayout name="secret" label="Secret value">
<FormControl>
<div className="relative">
<Input
<Textarea
id="secret"
type={showSecretValue ? 'text' : 'password'}
{...field}
rows={1}
ref={(el) => {
field.ref(el)
if (el) {
el.style.height = 'auto'
el.style.height = Math.max(40, el.scrollHeight) + 'px'
}
}}
data-lpignore="true"
className="min-h-0 resize-none"
style={
{
WebkitTextSecurity: showSecretValue ? undefined : 'disc',
} as React.CSSProperties
}
onChange={(e) => {
field.onChange(e)
e.currentTarget.style.height = 'auto'
e.currentTarget.style.height =
Math.max(40, e.currentTarget.scrollHeight) + 'px'
}}
/>
<Button
type="default"
@@ -86,9 +86,10 @@ describe(`EditSecretModal`, () => {
expect(nameInput).toHaveValue(secret.name)
expect(descriptionInput).toHaveValue(secret.description)
expect(valueInput).toHaveAttribute(`type`, `password`)
expect(valueInput.tagName).toBe('TEXTAREA')
expect((valueInput as any).style.WebkitTextSecurity).toBe('disc')
await userEvent.click(togglePasswordButton)
expect(valueInput).toHaveAttribute(`type`, `text`)
expect((valueInput as any).style.WebkitTextSecurity).toBe('')
await userEvent.type(nameInput, `updated-name`)
await userEvent.clear(descriptionInput)