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