| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import { zodResolver } from '@hookform/resolvers/zod'
- import { useParams } from 'common'
- import { Eye, EyeOff, MinusCircle } from 'lucide-react'
- import { useState } from 'react'
- import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
- import { toast } from 'sonner'
- import {
- Button,
- Card,
- CardContent,
- CardFooter,
- CardHeader,
- CardTitle,
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- } from 'ui'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import z from 'zod'
- import { DuplicateSecretWarningModal } from './DuplicateSecretWarningModal'
- import { useSecretsCreateMutation } from '@/data/secrets/secrets-create-mutation'
- import { useSecretsQuery } from '@/data/secrets/secrets-query'
- type SecretPair = {
- name: string
- value: string
- }
- const FormSchema = z.object({
- secrets: z.array(
- z.object({
- name: z
- .string()
- .min(1, 'Please provide a name for your secret')
- .refine((value) => !value.match(/^(BRIVEN_).*/), {
- message: 'Name must not start with the BRIVEN_ prefix',
- }),
- value: z.string().min(1, 'Please provide a value for your secret'),
- })
- ),
- })
- const defaultValues = {
- secrets: [{ name: '', value: '' }],
- }
- const removeWrappingQuotes = (str: string): string => {
- if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
- return str.slice(1, -1)
- }
- return str
- }
- export const AddNewSecretForm = () => {
- const { ref: projectRef } = useParams()
- const [visibleSecrets, setVisibleSecrets] = useState<Set<string>>(new Set())
- const [duplicateSecretName, setDuplicateSecretName] = useState<string>('')
- const [pendingSecrets, setPendingSecrets] = useState<z.infer<typeof FormSchema> | null>(null)
- const form = useForm({
- resolver: zodResolver(FormSchema as any),
- defaultValues,
- })
- const { fields, append, remove } = useFieldArray({
- control: form.control,
- name: 'secrets',
- })
- const { data: existingSecrets } = useSecretsQuery({
- projectRef: projectRef,
- })
- function handlePaste(e: ClipboardEvent) {
- e.preventDefault()
- const text = e.clipboardData?.getData('text')
- if (!text) return
- // If text doesn't contain '=' and is being pasted into a specific field, handle as single value
- if (!text.includes('=')) {
- const inputName = (e.target as HTMLInputElement).name
- if (inputName?.includes('secrets')) {
- const [_, indexStr, field] = inputName.match(/secrets\.(\d+)\.(\w+)/) || []
- if (indexStr && field) {
- const index = parseInt(indexStr)
- form.setValue(
- `secrets.${index}.${field}` as `secrets.${number}.name` | `secrets.${number}.value`,
- text.trim()
- )
- return
- }
- }
- }
- const pairs: Array<SecretPair> = []
- try {
- const jsonData = JSON.parse(text)
- Object.entries(jsonData).forEach(([key, value]) => {
- pairs.push({ name: key, value: String(value) })
- })
- } catch {
- // Try KEY=VALUE format (multiple lines)
- const lines = text.split(/\n/)
- lines.forEach((line) => {
- const [key, ...valueParts] = line.split('=')
- if (key && valueParts.length) {
- const valueStr = valueParts.join('=').trim()
- pairs.push({
- name: key.trim(),
- value: removeWrappingQuotes(valueStr),
- })
- }
- })
- }
- if (pairs.length) {
- const currentSecrets = form.getValues('secrets')
- // Filter out any empty pairs before combining
- const nonEmptySecrets = currentSecrets.filter((secret) => secret.name || secret.value)
- form.setValue('secrets', [...nonEmptySecrets, ...pairs])
- }
- }
- const { mutate: createSecret, isPending: isCreating } = useSecretsCreateMutation({
- onSuccess: (_, variables) => {
- toast.success(`Successfully created new secret "${variables.secrets[0].name}"`)
- // RHF recommends using setTimeout/useEffect to reset the form
- setTimeout(() => {
- form.reset()
- setVisibleSecrets(new Set())
- }, 0)
- },
- })
- const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
- // Check for duplicate secret names
- const existingSecretNames = existingSecrets?.map((secret) => secret.name) || []
- const duplicateSecret = data.secrets.find((secret) => existingSecretNames.includes(secret.name))
- if (duplicateSecret) {
- setDuplicateSecretName(duplicateSecret.name)
- setPendingSecrets(data)
- return
- }
- createSecret({ projectRef, secrets: data.secrets })
- }
- const handleConfirmDuplicate = () => {
- if (pendingSecrets) {
- createSecret({ projectRef, secrets: pendingSecrets.secrets })
- setDuplicateSecretName('')
- setPendingSecrets(null)
- }
- }
- const handleCancelDuplicate = () => {
- setDuplicateSecretName('')
- setPendingSecrets(null)
- }
- const handleToggleSecretVisibility = (fieldId: string) => {
- setVisibleSecrets((prev) => {
- const visibleSet = new Set(prev)
- if (visibleSet.has(fieldId)) {
- visibleSet.delete(fieldId)
- } else {
- visibleSet.add(fieldId)
- }
- return visibleSet
- })
- }
- const handleRemoveSecret = (fieldId: string, index: number) => {
- if (fields.length > 1) {
- setVisibleSecrets((prev) => {
- const visibleSet = new Set(prev)
- visibleSet.delete(fieldId)
- return visibleSet
- })
- remove(index)
- } else {
- form.reset(defaultValues)
- setVisibleSecrets(new Set())
- }
- }
- const handleAddAnotherSecret = () => {
- append({ name: '', value: '' })
- }
- const isSecretVisible = (fieldId: string) => visibleSecrets.has(fieldId)
- return (
- <>
- <Form {...form}>
- <form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
- <Card>
- <CardHeader>
- <CardTitle>Add or replace secrets</CardTitle>
- </CardHeader>
- <CardContent>
- {fields.map((fieldItem, index) => (
- <div key={fieldItem.id} className="grid grid-cols-[1fr_1fr_auto] gap-4 mb-4">
- <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)}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name={`secrets.${index}.value`}
- render={({ field }) => (
- <FormItem className="w-full relative">
- <FormLabel>Value</FormLabel>
- <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">
- <Button
- type="text"
- className="px-1"
- icon={isSecretVisible(fieldItem.id) ? <EyeOff /> : <Eye />}
- onClick={() => handleToggleSecretVisibility(fieldItem.id)}
- />
- </div>
- }
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <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>
- </CardFooter>
- </Card>
- </form>
- </Form>
- <DuplicateSecretWarningModal
- visible={!!duplicateSecretName}
- onCancel={handleCancelDuplicate}
- onConfirm={handleConfirmDuplicate}
- isCreating={isCreating}
- secretName={duplicateSecretName}
- />
- </>
- )
- }
|