AddNewSecretForm.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { useParams } from 'common'
  3. import { Eye, EyeOff, MinusCircle } from 'lucide-react'
  4. import { useState } from 'react'
  5. import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'
  6. import { toast } from 'sonner'
  7. import {
  8. Button,
  9. Card,
  10. CardContent,
  11. CardFooter,
  12. CardHeader,
  13. CardTitle,
  14. Form,
  15. FormControl,
  16. FormField,
  17. FormItem,
  18. FormLabel,
  19. FormMessage,
  20. } from 'ui'
  21. import { Input } from 'ui-patterns/DataInputs/Input'
  22. import z from 'zod'
  23. import { DuplicateSecretWarningModal } from './DuplicateSecretWarningModal'
  24. import { useSecretsCreateMutation } from '@/data/secrets/secrets-create-mutation'
  25. import { useSecretsQuery } from '@/data/secrets/secrets-query'
  26. type SecretPair = {
  27. name: string
  28. value: string
  29. }
  30. const FormSchema = z.object({
  31. secrets: z.array(
  32. z.object({
  33. name: z
  34. .string()
  35. .min(1, 'Please provide a name for your secret')
  36. .refine((value) => !value.match(/^(BRIVEN_).*/), {
  37. message: 'Name must not start with the BRIVEN_ prefix',
  38. }),
  39. value: z.string().min(1, 'Please provide a value for your secret'),
  40. })
  41. ),
  42. })
  43. const defaultValues = {
  44. secrets: [{ name: '', value: '' }],
  45. }
  46. const removeWrappingQuotes = (str: string): string => {
  47. if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
  48. return str.slice(1, -1)
  49. }
  50. return str
  51. }
  52. export const AddNewSecretForm = () => {
  53. const { ref: projectRef } = useParams()
  54. const [visibleSecrets, setVisibleSecrets] = useState<Set<string>>(new Set())
  55. const [duplicateSecretName, setDuplicateSecretName] = useState<string>('')
  56. const [pendingSecrets, setPendingSecrets] = useState<z.infer<typeof FormSchema> | null>(null)
  57. const form = useForm({
  58. resolver: zodResolver(FormSchema as any),
  59. defaultValues,
  60. })
  61. const { fields, append, remove } = useFieldArray({
  62. control: form.control,
  63. name: 'secrets',
  64. })
  65. const { data: existingSecrets } = useSecretsQuery({
  66. projectRef: projectRef,
  67. })
  68. function handlePaste(e: ClipboardEvent) {
  69. e.preventDefault()
  70. const text = e.clipboardData?.getData('text')
  71. if (!text) return
  72. // If text doesn't contain '=' and is being pasted into a specific field, handle as single value
  73. if (!text.includes('=')) {
  74. const inputName = (e.target as HTMLInputElement).name
  75. if (inputName?.includes('secrets')) {
  76. const [_, indexStr, field] = inputName.match(/secrets\.(\d+)\.(\w+)/) || []
  77. if (indexStr && field) {
  78. const index = parseInt(indexStr)
  79. form.setValue(
  80. `secrets.${index}.${field}` as `secrets.${number}.name` | `secrets.${number}.value`,
  81. text.trim()
  82. )
  83. return
  84. }
  85. }
  86. }
  87. const pairs: Array<SecretPair> = []
  88. try {
  89. const jsonData = JSON.parse(text)
  90. Object.entries(jsonData).forEach(([key, value]) => {
  91. pairs.push({ name: key, value: String(value) })
  92. })
  93. } catch {
  94. // Try KEY=VALUE format (multiple lines)
  95. const lines = text.split(/\n/)
  96. lines.forEach((line) => {
  97. const [key, ...valueParts] = line.split('=')
  98. if (key && valueParts.length) {
  99. const valueStr = valueParts.join('=').trim()
  100. pairs.push({
  101. name: key.trim(),
  102. value: removeWrappingQuotes(valueStr),
  103. })
  104. }
  105. })
  106. }
  107. if (pairs.length) {
  108. const currentSecrets = form.getValues('secrets')
  109. // Filter out any empty pairs before combining
  110. const nonEmptySecrets = currentSecrets.filter((secret) => secret.name || secret.value)
  111. form.setValue('secrets', [...nonEmptySecrets, ...pairs])
  112. }
  113. }
  114. const { mutate: createSecret, isPending: isCreating } = useSecretsCreateMutation({
  115. onSuccess: (_, variables) => {
  116. toast.success(`Successfully created new secret "${variables.secrets[0].name}"`)
  117. // RHF recommends using setTimeout/useEffect to reset the form
  118. setTimeout(() => {
  119. form.reset()
  120. setVisibleSecrets(new Set())
  121. }, 0)
  122. },
  123. })
  124. const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => {
  125. // Check for duplicate secret names
  126. const existingSecretNames = existingSecrets?.map((secret) => secret.name) || []
  127. const duplicateSecret = data.secrets.find((secret) => existingSecretNames.includes(secret.name))
  128. if (duplicateSecret) {
  129. setDuplicateSecretName(duplicateSecret.name)
  130. setPendingSecrets(data)
  131. return
  132. }
  133. createSecret({ projectRef, secrets: data.secrets })
  134. }
  135. const handleConfirmDuplicate = () => {
  136. if (pendingSecrets) {
  137. createSecret({ projectRef, secrets: pendingSecrets.secrets })
  138. setDuplicateSecretName('')
  139. setPendingSecrets(null)
  140. }
  141. }
  142. const handleCancelDuplicate = () => {
  143. setDuplicateSecretName('')
  144. setPendingSecrets(null)
  145. }
  146. const handleToggleSecretVisibility = (fieldId: string) => {
  147. setVisibleSecrets((prev) => {
  148. const visibleSet = new Set(prev)
  149. if (visibleSet.has(fieldId)) {
  150. visibleSet.delete(fieldId)
  151. } else {
  152. visibleSet.add(fieldId)
  153. }
  154. return visibleSet
  155. })
  156. }
  157. const handleRemoveSecret = (fieldId: string, index: number) => {
  158. if (fields.length > 1) {
  159. setVisibleSecrets((prev) => {
  160. const visibleSet = new Set(prev)
  161. visibleSet.delete(fieldId)
  162. return visibleSet
  163. })
  164. remove(index)
  165. } else {
  166. form.reset(defaultValues)
  167. setVisibleSecrets(new Set())
  168. }
  169. }
  170. const handleAddAnotherSecret = () => {
  171. append({ name: '', value: '' })
  172. }
  173. const isSecretVisible = (fieldId: string) => visibleSecrets.has(fieldId)
  174. return (
  175. <>
  176. <Form {...form}>
  177. <form className="w-full" onSubmit={form.handleSubmit(onSubmit)}>
  178. <Card>
  179. <CardHeader>
  180. <CardTitle>Add or replace secrets</CardTitle>
  181. </CardHeader>
  182. <CardContent>
  183. {fields.map((fieldItem, index) => (
  184. <div key={fieldItem.id} className="grid grid-cols-[1fr_1fr_auto] gap-4 mb-4">
  185. <FormField
  186. control={form.control}
  187. name={`secrets.${index}.name`}
  188. render={({ field }) => (
  189. <FormItem className="w-full">
  190. <FormLabel>Name</FormLabel>
  191. <FormControl>
  192. <Input
  193. {...field}
  194. placeholder="e.g. CLIENT_KEY"
  195. data-1p-ignore
  196. data-lpignore="true"
  197. data-form-type="other"
  198. data-bwignore
  199. onPaste={(e) => handlePaste(e.nativeEvent)}
  200. />
  201. </FormControl>
  202. <FormMessage />
  203. </FormItem>
  204. )}
  205. />
  206. <FormField
  207. control={form.control}
  208. name={`secrets.${index}.value`}
  209. render={({ field }) => (
  210. <FormItem className="w-full relative">
  211. <FormLabel>Value</FormLabel>
  212. <FormControl>
  213. <Input
  214. {...field}
  215. type={isSecretVisible(fieldItem.id) ? 'text' : 'password'}
  216. data-1p-ignore
  217. data-lpignore="true"
  218. data-form-type="other"
  219. data-bwignore
  220. actions={
  221. <div className="mr-1">
  222. <Button
  223. type="text"
  224. className="px-1"
  225. icon={isSecretVisible(fieldItem.id) ? <EyeOff /> : <Eye />}
  226. onClick={() => handleToggleSecretVisibility(fieldItem.id)}
  227. />
  228. </div>
  229. }
  230. />
  231. </FormControl>
  232. <FormMessage />
  233. </FormItem>
  234. )}
  235. />
  236. <Button
  237. type="default"
  238. className="h-[34px] mt-6"
  239. icon={<MinusCircle />}
  240. disabled={fields.length <= 1}
  241. onClick={() => handleRemoveSecret(fieldItem.id, index)}
  242. />
  243. </div>
  244. ))}
  245. <Button type="default" onClick={handleAddAnotherSecret}>
  246. Add another
  247. </Button>
  248. </CardContent>
  249. <CardFooter className="justify-between space-x-2">
  250. <p className="text-sm text-foreground-muted">
  251. Insert or update multiple secrets at once by pasting key-value pairs
  252. </p>
  253. <Button type="primary" htmlType="submit" disabled={isCreating} loading={isCreating}>
  254. {isCreating ? 'Saving...' : fields.length > 1 ? 'Bulk save' : 'Save'}
  255. </Button>
  256. </CardFooter>
  257. </Card>
  258. </form>
  259. </Form>
  260. <DuplicateSecretWarningModal
  261. visible={!!duplicateSecretName}
  262. onCancel={handleCancelDuplicate}
  263. onConfirm={handleConfirmDuplicate}
  264. isCreating={isCreating}
  265. secretName={duplicateSecretName}
  266. />
  267. </>
  268. )
  269. }