feat(studio): improve new secret form ux (#40142)

Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
This commit is contained in:
Kalleby Santos
2025-11-07 20:04:25 +00:00
committed by GitHub
parent 2415776436
commit 5606d07fc2
4 changed files with 377 additions and 30 deletions

View File

@@ -168,7 +168,7 @@ const AddNewSecretForm = () => {
<form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Add new secrets</CardTitle>
<CardTitle>Add or replace secrets</CardTitle>
</CardHeader>
<CardContent>
{fields.map((fieldItem, index) => (
@@ -178,7 +178,7 @@ const AddNewSecretForm = () => {
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormLabel_Shadcn_>Key</FormLabel_Shadcn_>
<FormLabel_Shadcn_>Name</FormLabel_Shadcn_>
<FormControl_Shadcn_>
<Input
{...field}
@@ -246,9 +246,13 @@ const AddNewSecretForm = () => {
Add another
</Button>
</CardContent>
<CardFooter className="justify-end space-x-2">
<CardFooter className="justify-between space-x-2">
<p className="text-sm text-foreground-lighter">
Insert or update multiple secrets at once by pasting key-value pairs
</p>
<Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
{isCreating ? 'Saving...' : 'Save'}
{isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
</Button>
</CardFooter>
</Card>

View File

@@ -1,18 +1,28 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Trash } from 'lucide-react'
import { Edit2, MoreVertical, Trash } from 'lucide-react'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { ProjectSecret } from 'data/secrets/secrets-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TableCell, TableRow } from 'ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
TableCell,
TableRow,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
interface EdgeFunctionSecretProps {
secret: ProjectSecret
onSelectDelete: () => void
onSelectEdit: () => void
}
const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) => {
const EdgeFunctionSecret = ({ secret, onSelectEdit, onSelectDelete }: EdgeFunctionSecretProps) => {
const { can: canUpdateSecrets } = useAsyncCheckPermissions(PermissionAction.SECRETS_WRITE, '*')
// [Joshen] Following API's validation:
// https://github.com/supabase/infrastructure/blob/develop/api/src/routes/v1/projects/ref/secrets/secrets.controller.ts#L106
@@ -45,23 +55,63 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
</TableCell>
<TableCell>
<div className="flex items-center justify-end">
<ButtonTooltip
type="text"
icon={<Trash />}
className="px-1"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More options"
type="default"
className="px-1"
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-52">
<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Edit2 size={14} />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectEdit()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be changed'
: !canUpdateSecrets
? 'You need additional permissions to edit edge function secrets'
: undefined,
},
}}
>
Edit secret
</ButtonTooltip>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<ButtonTooltip
type="text"
icon={<Trash size={14} className="group-[&:not(:disabled)]:text-destructive" />}
className="w-full justify-start group text-inherit"
disabled={!canUpdateSecrets || isReservedSecret}
onClick={() => onSelectDelete()}
tooltip={{
content: {
side: 'bottom',
text: isReservedSecret
? 'This is a reserved secret and cannot be deleted'
: !canUpdateSecrets
? 'You need additional permissions to delete edge function secrets'
: undefined,
},
}}
>
Delete secret
</ButtonTooltip>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>

View File

@@ -15,11 +15,17 @@ import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import AddNewSecretForm from './AddNewSecretForm'
import EdgeFunctionSecret from './EdgeFunctionSecret'
import { EditSecretSheet } from './EditSecretSheet'
type SelectedProjectSecret = {
secret: ProjectSecret
op: 'delete' | 'edit'
}
const EdgeFunctionSecrets = () => {
const { ref: projectRef } = useParams()
const [searchString, setSearchString] = useState('')
const [selectedSecret, setSelectedSecret] = useState<ProjectSecret>()
const [selectedSecret, setSelectedSecret] = useState<SelectedProjectSecret>()
const { can: canReadSecrets, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
@@ -33,7 +39,7 @@ const EdgeFunctionSecrets = () => {
const { mutate: deleteSecret, isLoading: isDeleting } = useSecretsDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted ${selectedSecret?.name}`)
toast.success(`Successfully deleted ${selectedSecret?.secret.name}`)
setSelectedSecret(undefined)
},
})
@@ -99,7 +105,8 @@ const EdgeFunctionSecrets = () => {
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
onSelectEdit={() => setSelectedSecret({ secret, op: 'edit' })}
onSelectDelete={() => setSelectedSecret({ secret, op: 'delete' })}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
@@ -131,17 +138,23 @@ const EdgeFunctionSecrets = () => {
</>
)}
<EditSecretSheet
secret={selectedSecret?.secret}
visible={selectedSecret?.op === 'edit'}
onClose={() => setSelectedSecret(undefined)}
/>
<ConfirmationModal
variant="destructive"
loading={isDeleting}
visible={selectedSecret !== undefined}
visible={selectedSecret?.op === 'delete'}
confirmLabel="Delete secret"
confirmLabelLoading="Deleting secret"
title={`Confirm to delete secret "${selectedSecret?.name}"`}
title={`Confirm to delete secret "${selectedSecret?.secret.name}"`}
onCancel={() => setSelectedSecret(undefined)}
onConfirm={() => {
if (selectedSecret !== undefined) {
deleteSecret({ projectRef, secrets: [selectedSecret.name] })
deleteSecret({ projectRef, secrets: [selectedSecret.secret.name] })
}
}}
>

View File

@@ -0,0 +1,280 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { SubmitHandler, useForm, type UseFormReturn } 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 { ProjectSecret } from 'data/secrets/secrets-query'
import { Eye, EyeOff, X } from 'lucide-react'
import { useLatest } from 'react-use'
import {
Button,
cn,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
Separator,
Sheet,
SheetClose,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const FORM_ID = 'edit-secret-sidepanel'
const FormSchema = z.object({
name: z.string().min(1, 'Please provide a name for your secret'),
value: z.string().min(1, 'Please provide a value for your secret'),
})
type FormSchemaType = z.infer<typeof FormSchema>
interface EditSecretSheetProps {
secret?: ProjectSecret
visible: boolean
onClose: () => void
}
export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetProps) {
const secretName = useLatest(secret?.name)
const form = useForm<FormSchemaType>({
resolver: zodResolver(FormSchema),
})
useEffect(() => {
if (visible) {
form.reset({
name: secretName.current ?? '',
value: '',
})
}
}, [form, secretName, visible])
const isValid = form.formState.isValid
const { ref: projectRef } = useParams()
const { mutate: updateSecret, isLoading: isUpdating } = useSecretsCreateMutation({
onSuccess: (_, variables) => {
toast.success(`Successfully updated secret "${variables.secrets[0].name}"`)
onClose()
},
})
const onSubmit: SubmitHandler<FormSchemaType> = async ({ name, value }) => {
updateSecret({
projectRef,
secrets: [{ name, value }],
})
}
const { confirmOnClose, modal: closeConfirmationModal } = useConfirmOnClose({
checkIsDirty: () => form.formState.isDirty,
onClose,
})
return (
<Sheet open={visible} onOpenChange={confirmOnClose}>
<SheetContent
showClose={false}
size={'default'}
className={'!min-w-screen lg:!min-w-[600px] flex flex-col'}
>
<Header />
<Separator />
<FormBody form={form} onSubmit={onSubmit} />
<SheetFooter>
<Button disabled={isUpdating} type="default" onClick={confirmOnClose}>
Cancel
</Button>
<Button form={FORM_ID} htmlType="submit" disabled={!isValid} loading={isUpdating}>
Save
</Button>
</SheetFooter>
</SheetContent>
{closeConfirmationModal}
</Sheet>
)
}
const Header = (): ReactNode => {
return (
<SheetHeader className="py-3 flex flex-row gap-3 items-center border-b-0">
<SheetClose
className={cn(
'text-muted hover:text ring-offset-background hover:opacity-100',
'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none data-[state=open]:bg-secondary',
'transition'
)}
>
<X className="h-3 w-3" />
<span className="sr-only">Close</span>
</SheetClose>
<SheetTitle>Edit secret</SheetTitle>
</SheetHeader>
)
}
type FormBodyProps = {
form: UseFormReturn<FormSchemaType>
onSubmit: SubmitHandler<FormSchemaType>
}
const FormBody = ({ form, onSubmit }: FormBodyProps): ReactNode => {
return (
<Form_Shadcn_ {...form}>
<form id={FORM_ID} className="flex-grow overflow-auto" onSubmit={form.handleSubmit(onSubmit)}>
<SheetSection>
<NameField form={form} />
</SheetSection>
<Separator />
<SheetSection className="space-y-4">
<SecretField form={form} />
</SheetSection>
<Separator />
</form>
</Form_Shadcn_>
)
}
type NameFieldProps = {
form: UseFormReturn<FormSchemaType>
}
const NameField = ({ form }: NameFieldProps): ReactNode => {
return (
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
readOnly
className="!text-foreground-light cursor-not-allowed"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
type SecretFieldProps = {
form: UseFormReturn<FormSchemaType>
}
const SecretField = ({ form }: SecretFieldProps): ReactNode => {
const [showSecretValue, setShowSecretValue] = useState(false)
return (
<FormField_Shadcn_
control={form.control}
name="value"
render={({ field }) => (
<FormItemLayout
label="Value"
layout="horizontal"
description="Secrets cant be retrieved once saved. Enter a new value to overwrite the existing value."
>
<FormControl_Shadcn_>
<Input
{...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>
}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
type UseConfirmOnCloseParams = {
checkIsDirty: () => boolean
onClose: () => void
}
type ConfirmOnCloseReturn = {
confirmOnClose: () => void
modal: ReactNode
}
const useConfirmOnClose = ({
checkIsDirty,
onClose,
}: UseConfirmOnCloseParams): ConfirmOnCloseReturn => {
const [visible, setVisible] = useState(false)
const confirmOnClose = useCallback(() => {
if (checkIsDirty()) {
setVisible(true)
} else {
onClose()
}
}, [checkIsDirty, onClose])
const onConfirm = () => {
setVisible(false)
onClose()
}
return {
confirmOnClose,
modal: (
<CloseConfirmationModal
visible={visible}
onClose={onConfirm}
onCancel={() => setVisible(false)}
/>
),
}
}
type CloseConfirmationModalProps = {
visible: boolean
onClose: () => void
onCancel: () => void
}
const CloseConfirmationModal = ({
visible,
onClose,
onCancel,
}: CloseConfirmationModalProps): ReactNode => {
return (
<ConfirmationModal
visible={visible}
title="Discard changes"
confirmLabel="Discard"
onCancel={onCancel}
onConfirm={onClose}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
)
}