mirror of
https://github.com/supabase/supabase.git
synced 2026-06-01 02:14:43 +08:00
feat(studio): improve new secret form ux (#40142)
Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] })
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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 can’t 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user