mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 21:59:16 +03:00
add missing changes
This commit is contained in:
1 parent
4f9092fa23
commit
b2cd7a2e34
3 files changed
+299
-118
No files matched your search
+173
-117
@@ -1,31 +1,67 @@
|
||||
import { isEmpty } from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
|
||||
import { useVaultSecretUpdateMutation } from 'data/vault/vault-secret-update-mutation'
|
||||
import type { VaultSecret } from 'types'
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
} from 'ui'
|
||||
import { EyeOff, Eye } from 'lucide-react'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
interface EditSecretModalProps {
|
||||
selectedSecret: VaultSecret | undefined
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SecretSchema = z.object({
|
||||
name: z.string().min(1, 'Please provide a name for your secret'),
|
||||
description: z.string().optional(),
|
||||
secret: z.string().min(1, 'Please enter your secret value'),
|
||||
})
|
||||
|
||||
const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
const [showSecretValue, setShowSecretValue] = useState(false)
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const { mutateAsync: updateSecret } = useVaultSecretUpdateMutation()
|
||||
|
||||
let INITIAL_VALUES = {
|
||||
name: selectedSecret?.name ?? '',
|
||||
description: selectedSecret?.description ?? '',
|
||||
secret: selectedSecret?.decryptedSecret ?? '',
|
||||
}
|
||||
const formId = 'edit-vault-secret-form'
|
||||
const { data, isLoading: isLoadingSecretValue } = useVaultSecretDecryptedValueQuery(
|
||||
{
|
||||
projectRef: project?.ref!,
|
||||
id: selectedSecret?.id!,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: selectedSecret !== undefined && !!(project?.ref && selectedSecret?.id) }
|
||||
)
|
||||
const form = useForm<z.infer<typeof SecretSchema>>({
|
||||
resolver: zodResolver(SecretSchema),
|
||||
defaultValues: {
|
||||
name: selectedSecret?.name ?? '',
|
||||
description: selectedSecret?.description ?? '',
|
||||
secret: selectedSecret?.decryptedSecret ?? data ?? '',
|
||||
},
|
||||
values: {
|
||||
name: selectedSecret?.name ?? '',
|
||||
description: selectedSecret?.description ?? '',
|
||||
secret: selectedSecret?.decryptedSecret ?? data ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSecret !== undefined) {
|
||||
@@ -33,126 +69,146 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
|
||||
}
|
||||
}, [selectedSecret])
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {}
|
||||
if (values.name.length === 0) errors.name = 'Please provide a name for your secret'
|
||||
if (values.secret.length === 0) errors.secret = 'Please enter your secret value'
|
||||
return errors
|
||||
}
|
||||
const { mutateAsync: updateSecret, isLoading: isSubmitting } = useVaultSecretUpdateMutation()
|
||||
|
||||
const onUpdateSecret = async (values: any, { setSubmitting }: any) => {
|
||||
const onSubmit: SubmitHandler<z.infer<typeof SecretSchema>> = async (values) => {
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
try {
|
||||
const payload: Partial<VaultSecret> = {}
|
||||
if (values.name !== selectedSecret?.name) payload.name = values.name
|
||||
if (values.description !== selectedSecret?.description)
|
||||
payload.description = values.description
|
||||
payload.secret = values.secret
|
||||
const payload: Partial<VaultSecret> = {
|
||||
secret: values.secret,
|
||||
}
|
||||
if (values.name !== selectedSecret?.name) payload.name = values.name
|
||||
if (values.description !== selectedSecret?.description) payload.description = values.description
|
||||
|
||||
if (!isEmpty(payload) && selectedSecret) {
|
||||
setSubmitting(true)
|
||||
const res = await updateSecret({
|
||||
if (!isEmpty(payload) && selectedSecret) {
|
||||
updateSecret(
|
||||
{
|
||||
projectRef: project.ref,
|
||||
connectionString: project?.connectionString,
|
||||
id: selectedSecret.id,
|
||||
...payload,
|
||||
})
|
||||
if (!res.error) {
|
||||
toast.success('Successfully updated secret')
|
||||
setSubmitting(false)
|
||||
onClose()
|
||||
} else {
|
||||
toast.error(`Failed to update secret: ${res.error.message}`)
|
||||
setSubmitting(false)
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully updated secret')
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to update secret: ${error.message}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
hideFooter
|
||||
size="medium"
|
||||
visible={selectedSecret !== undefined}
|
||||
onCancel={onClose}
|
||||
header={<h5 className="text-sm text-foreground">Edit secret</h5>}
|
||||
<Dialog
|
||||
open={selectedSecret !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
form.reset()
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
id="add-new-secret-form"
|
||||
initialValues={INITIAL_VALUES}
|
||||
validate={validate}
|
||||
validateOnBlur={false}
|
||||
onSubmit={onUpdateSecret}
|
||||
>
|
||||
{({ isSubmitting, resetForm }: any) => {
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingSecretValue,
|
||||
isSuccess: isSuccessSecretValue,
|
||||
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
|
||||
// using react hook form instead
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
} = useVaultSecretDecryptedValueQuery(
|
||||
{
|
||||
projectRef: project?.ref!,
|
||||
id: selectedSecret?.id!,
|
||||
connectionString: project?.connectionString,
|
||||
},
|
||||
{ enabled: selectedSecret !== undefined && !!(project?.ref && selectedSecret?.id) }
|
||||
)
|
||||
|
||||
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
|
||||
// using react hook form instead
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (selectedSecret !== undefined && isSuccessSecretValue) {
|
||||
resetForm({
|
||||
values: { ...INITIAL_VALUES, secret: data },
|
||||
initialValues: { ...INITIAL_VALUES, secret: data },
|
||||
})
|
||||
}
|
||||
}, [selectedSecret, isSuccessSecretValue])
|
||||
|
||||
return isLoadingSecretValue ? (
|
||||
<Modal.Content>
|
||||
<GenericSkeletonLoader />
|
||||
</Modal.Content>
|
||||
) : (
|
||||
<>
|
||||
<Modal.Content className="space-y-4">
|
||||
<Input id="name" label="Name" />
|
||||
<Input id="description" label="Description" labelOptional="Optional" />
|
||||
<Input
|
||||
id="secret"
|
||||
type={showSecretValue ? 'text' : 'password'}
|
||||
label="Secret value"
|
||||
actions={
|
||||
<div className="mr-1">
|
||||
<Button
|
||||
type="default"
|
||||
icon={showSecretValue ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setShowSecretValue(!showSecretValue)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="flex items-center justify-end space-x-2">
|
||||
<Button type="default" disabled={isSubmitting} onClick={() => onClose()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
Update secret
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Modal>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit secret</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
{isLoadingSecretValue ? (
|
||||
<DialogSection>
|
||||
<GenericSkeletonLoader />
|
||||
</DialogSection>
|
||||
) : (
|
||||
<>
|
||||
<DialogSection>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
autoComplete="off"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField_Shadcn_
|
||||
key="name"
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="name" label="Name">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="name" {...field} />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
key="description"
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
name="description"
|
||||
label="Description"
|
||||
labelOptional="Optional"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_ id="description" {...field} data-lpignore="true" />
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
<FormField_Shadcn_
|
||||
key="secret"
|
||||
name="secret"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItemLayout name="secret" label="Secret value">
|
||||
<FormControl_Shadcn_>
|
||||
<div className="relative">
|
||||
<Input_Shadcn_
|
||||
id="secret"
|
||||
type={showSecretValue ? 'text' : 'password'}
|
||||
{...field}
|
||||
data-lpignore="true"
|
||||
/>
|
||||
<Button
|
||||
type="default"
|
||||
title={showSecretValue ? `Hide secret value` : `Show secret value`}
|
||||
aria-label={
|
||||
showSecretValue ? `Hide secret value` : `Show secret value`
|
||||
}
|
||||
className="absolute right-2 top-1 px-3 py-2"
|
||||
icon={showSecretValue ? <EyeOff /> : <Eye />}
|
||||
onClick={() => setShowSecretValue(!showSecretValue)}
|
||||
/>
|
||||
</div>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</DialogSection>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => {
|
||||
form.reset()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button form={formId} htmlType="submit" loading={isSubmitting}>
|
||||
Update secret
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) =>
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="text" className="px-1" icon={<MoreVertical />} />
|
||||
<Button title="Manage Secret" type="text" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<Tooltip>
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { mockAnimationsApi } from 'jsdom-testing-mocks'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from 'tests/helpers'
|
||||
import EditSecretModal from '../EditSecretModal'
|
||||
import { addAPIMock } from 'tests/lib/msw'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { Edit3, MoreVertical } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { VaultSecret } from 'types/ui'
|
||||
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
const secret = {
|
||||
id: '47ca58b4-01c5-4a71-8814-c73856b02e0e',
|
||||
name: 'test',
|
||||
description: 'new text',
|
||||
secret: 'NASR0SoksURJ0OorMJ9FzraTzcqSWk5u1PQa2r4c3w9rUVc=',
|
||||
created_at: '2025-07-13 12:57:47.62208+00',
|
||||
updated_at: '2025-07-13 14:51:37.818223+00',
|
||||
}
|
||||
|
||||
const Page = ({ onClose }: { onClose: () => void }) => {
|
||||
const [selectedSecretToEdit, setSelectedSecretToEdit] = useState<VaultSecret>()
|
||||
return (
|
||||
<ProjectContextProvider projectRef="default">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button title="Manage Secret" type="text" className="px-1" icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-32">
|
||||
<DropdownMenuItem
|
||||
className="space-x-2"
|
||||
disabled={false}
|
||||
onClick={() => setSelectedSecretToEdit(secret)}
|
||||
>
|
||||
<Edit3 size="14" />
|
||||
<p>Edit</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<EditSecretModal
|
||||
selectedSecret={selectedSecretToEdit}
|
||||
onClose={() => {
|
||||
setSelectedSecretToEdit(undefined)
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</ProjectContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
mockAnimationsApi()
|
||||
|
||||
describe(`EditSecretModal`, () => {
|
||||
beforeEach(() => {
|
||||
// 'http://localhost:3000/api/platform/projects/default'
|
||||
addAPIMock({
|
||||
method: `get`,
|
||||
path: `/platform/projects/:ref`,
|
||||
// @ts-expect-error
|
||||
response: {
|
||||
cloud_provider: 'localhost',
|
||||
id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646Z',
|
||||
name: 'Default Project',
|
||||
organization_id: 1,
|
||||
ref: 'default',
|
||||
region: 'local',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
},
|
||||
})
|
||||
// 'http://localhost:3000/api/platform/pg-meta/default/query?key=projects-default-secrets-47ca58b4-01c5-4a71-8814-c73856b02e0e'
|
||||
// 'http://localhost:3000/api/platform/pg-meta/default/query?key='
|
||||
// useVaultSecretDecryptedValueQuery + useVaultSecretUpdateMutation
|
||||
// both call the same endpoint but execute different SQL queries
|
||||
addAPIMock({
|
||||
method: `post`,
|
||||
path: `/platform/pg-meta/:ref/query`,
|
||||
// @ts-expect-error this path erroneously has a `never` return type when it should be `unknown` since it executes a SQL query
|
||||
response: [{ decrypted_secret: 'bar', update_secret: '' }],
|
||||
})
|
||||
})
|
||||
|
||||
it(`renders a modal pre-filled with the secret's values`, async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Page onClose={onClose} />)
|
||||
|
||||
const menuTrigger = screen.getByRole(`button`, { name: `Manage Secret` })
|
||||
await userEvent.click(menuTrigger)
|
||||
const editOption = await screen.findByRole(`menuitem`, { name: `Edit` })
|
||||
await userEvent.click(editOption)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole(`dialog`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const nameInput = screen.getByLabelText(`Name`)
|
||||
const descriptionInput = screen.getByLabelText(`Description`)
|
||||
const valueInput = screen.getByLabelText(`Secret value`)
|
||||
const togglePasswordButton = screen.getByRole(`button`, { name: `Show secret value` })
|
||||
const submitButton = screen.getByRole(`button`, { name: `Update secret` })
|
||||
|
||||
expect(nameInput).toHaveValue(secret.name)
|
||||
expect(descriptionInput).toHaveValue(secret.description)
|
||||
expect(valueInput).toHaveAttribute(`type`, `password`)
|
||||
await userEvent.click(togglePasswordButton)
|
||||
expect(valueInput).toHaveAttribute(`type`, `text`)
|
||||
|
||||
await userEvent.type(nameInput, `updated-name`)
|
||||
await userEvent.clear(descriptionInput)
|
||||
await userEvent.type(valueInput, `qux`)
|
||||
|
||||
fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledOnce())
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user