mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
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:
1 parent
e92f13f11a
commit
47dbbddc91
6 files changed
+268
-77
No files matched your search
+69
@@ -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 }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
+87
-51
@@ -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>
|
||||
|
||||
+37
-20
@@ -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"
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user