CreateBucketModal.tsx425 lines · main
1// @ts-nocheck
2import { zodResolver } from '@hookform/resolvers/zod'
3import { useParams } from 'common'
4import { useState } from 'react'
5import { SubmitHandler, useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
8 Button,
9 Dialog,
10 DialogContent,
11 DialogFooter,
12 DialogHeader,
13 DialogSection,
14 DialogSectionSeparator,
15 DialogTitle,
16 Form,
17 FormControl,
18 FormField,
19 FormMessage,
20 Input,
21 Select,
22 SelectContent,
23 SelectItem,
24 SelectTrigger,
25 SelectValue,
26 Switch,
27} from 'ui'
28import { Admonition } from 'ui-patterns/admonition'
29import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
30import z from 'zod'
31
32import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
33import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
34import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
35import { InlineLink } from '@/components/ui/InlineLink'
36import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
37import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation'
38import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
39import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
40import { IS_PLATFORM } from '@/lib/constants'
41
42const FormSchema = z
43 .object({
44 name: z
45 .string()
46 .trim()
47 .min(1, 'Please provide a name for your bucket')
48 .max(100, 'Bucket name should be below 100 characters')
49 .refine(
50 (value) => !value.endsWith(' '),
51 'The name of the bucket cannot end with a whitespace'
52 )
53 .refine(
54 (value) => value !== 'public',
55 '"public" is a reserved name. Please choose another name'
56 ),
57 public: z.boolean().default(false),
58 has_file_size_limit: z.boolean().default(false),
59 formatted_size_limit: z.coerce
60 .number()
61 .min(0, 'File size upload limit has to be at least 0')
62 .optional(),
63 allowed_mime_types: z.string().trim().default(''),
64 })
65 .superRefine((data, ctx) => {
66 if (!validBucketNameRegex.test(data.name)) {
67 const [match] = data.name.match(inverseValidBucketNameRegex) ?? []
68 ctx.addIssue({
69 path: ['name'],
70 code: z.ZodIssueCode.custom,
71 message: !!match
72 ? `Bucket name cannot contain the "${match}" character`
73 : 'Bucket name contains an invalid special character',
74 })
75 }
76 })
77
78const formId = 'create-storage-bucket-form'
79
80export type CreateBucketForm = z.infer<typeof FormSchema>
81
82interface CreateBucketModalProps {
83 open: boolean
84 onOpenChange: (value: boolean) => void
85}
86
87export const CreateBucketModal = ({ open, onOpenChange }: CreateBucketModalProps) => {
88 const { ref } = useParams()
89 const { data: org } = useSelectedOrganizationQuery()
90
91 const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
92 const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
93
94 const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
95 const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
96 const formattedGlobalUploadLimit = `${value} ${unit}`
97
98 const { mutate: sendEvent } = useSendEventMutation()
99 const { mutateAsync: createBucket, isPending: isCreatingBucket } = useBucketCreateMutation({
100 // [Joshen] Silencing the error here as it's being handled in onSubmit
101 onError: () => {},
102 })
103
104 const form = useForm<CreateBucketForm>({
105 resolver: zodResolver(FormSchema as any),
106 defaultValues: {
107 name: '',
108 public: false,
109 has_file_size_limit: false,
110 formatted_size_limit: undefined,
111 allowed_mime_types: '',
112 },
113 })
114 const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors
115 const isPublicBucket = form.watch('public')
116 const hasFileSizeLimit = form.watch('has_file_size_limit')
117
118 const onSubmit: SubmitHandler<CreateBucketForm> = async (values) => {
119 if (!ref) return console.error('Project ref is required')
120
121 // [Joshen] Should shift this into superRefine in the form schema
122 try {
123 const fileSizeLimit =
124 values.has_file_size_limit && values.formatted_size_limit !== undefined
125 ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
126 : undefined
127
128 const allowedMimeTypes =
129 hasAllowedMimeTypes && values.allowed_mime_types.length > 0
130 ? values.allowed_mime_types.split(',').map((x) => x.trim())
131 : undefined
132
133 if (!!fileSizeLimit && !!data?.fileSizeLimit && fileSizeLimit > data.fileSizeLimit) {
134 return form.setError('formatted_size_limit', {
135 type: 'manual',
136 message: 'exceed_global_limit',
137 })
138 }
139
140 await createBucket({
141 projectRef: ref,
142 id: values.name,
143 type: 'STANDARD',
144 isPublic: values.public,
145 file_size_limit: fileSizeLimit,
146 allowed_mime_types: allowedMimeTypes,
147 })
148 sendEvent({
149 action: 'storage_bucket_created',
150 properties: { bucketType: 'STANDARD' },
151 groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
152 })
153
154 toast.success(`Successfully created bucket ${values.name}`)
155 form.reset()
156 setSelectedUnit(StorageSizeUnits.MB)
157 onOpenChange(false)
158 } catch (error: any) {
159 // Handle specific error cases for inline display
160 const errorMessage = error.message?.toLowerCase() || ''
161
162 if (
163 errorMessage.includes('mime type') &&
164 (errorMessage.includes('is not supported') || errorMessage.includes('not supported'))
165 ) {
166 // Set form error for the MIME types field
167 form.setError('allowed_mime_types', {
168 type: 'manual',
169 message: 'Invalid MIME type format. Please check your input.',
170 })
171 } else {
172 // For other errors, show a toast as fallback
173 toast.error(`Failed to create bucket: ${error.message}`)
174 }
175 }
176 }
177
178 const handleClose = () => {
179 form.reset()
180 setSelectedUnit(StorageSizeUnits.MB)
181 onOpenChange(false)
182 }
183
184 return (
185 <Dialog
186 open={open}
187 onOpenChange={(open) => {
188 if (!open) {
189 handleClose()
190 }
191 }}
192 >
193 <DialogContent aria-describedby={undefined}>
194 <DialogHeader>
195 <DialogTitle>Create file bucket</DialogTitle>
196 </DialogHeader>
197
198 <DialogSectionSeparator />
199
200 <Form {...form}>
201 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
202 <DialogSection className="flex flex-col gap-y-2">
203 <FormField
204 key="name"
205 name="name"
206 control={form.control}
207 render={({ field }) => (
208 <FormItemLayout
209 name="name"
210 label="Bucket name"
211 labelOptional="Cannot be changed after creation"
212 >
213 <FormControl>
214 <Input
215 id="name"
216 data-1p-ignore
217 data-lpignore="true"
218 data-form-type="other"
219 data-bwignore
220 {...field}
221 placeholder="Enter bucket name"
222 />
223 </FormControl>
224 </FormItemLayout>
225 )}
226 />
227 </DialogSection>
228
229 <DialogSectionSeparator />
230
231 <DialogSection className="space-y-3">
232 <FormField
233 key="public"
234 name="public"
235 control={form.control}
236 render={({ field }) => (
237 <FormItemLayout
238 hideMessage
239 name="public"
240 label="Public bucket"
241 description="Allow anyone to read objects without authorization"
242 layout="flex"
243 >
244 <FormControl>
245 <Switch
246 id="public"
247 size="large"
248 checked={field.value}
249 onCheckedChange={field.onChange}
250 />
251 </FormControl>
252 </FormItemLayout>
253 )}
254 />
255 {isPublicBucket && (
256 <Admonition
257 type="warning"
258 title="Public buckets are not protected"
259 description="Users can read objects in public buckets without any authorization. Row level security (RLS) policies are still required for other operations such as object uploads and deletes."
260 />
261 )}
262 </DialogSection>
263
264 <DialogSectionSeparator />
265
266 <DialogSection className="space-y-2">
267 <FormField
268 key="has_file_size_limit"
269 name="has_file_size_limit"
270 control={form.control}
271 render={({ field }) => (
272 <FormItemLayout
273 name="has_file_size_limit"
274 label="Restrict file size"
275 description="Prevent uploading of files larger than a specified limit"
276 layout="flex"
277 >
278 <FormControl>
279 <Switch
280 id="has_file_size_limit"
281 size="large"
282 checked={field.value}
283 onCheckedChange={field.onChange}
284 />
285 </FormControl>
286 </FormItemLayout>
287 )}
288 />
289
290 {hasFileSizeLimit && (
291 <div>
292 <FormField
293 key="formatted_size_limit"
294 name="formatted_size_limit"
295 control={form.control}
296 render={({ field }) => (
297 <FormItemLayout
298 hideMessage
299 name="formatted_size_limit"
300 label="File size limit"
301 >
302 <div className="grid grid-cols-12 gap-x-2">
303 <div className="col-span-8">
304 <FormControl>
305 <Input
306 id="formatted_size_limit"
307 aria-label="File size limit"
308 type="number"
309 min={0}
310 placeholder="0"
311 {...field}
312 />
313 </FormControl>
314 </div>
315 <div className="col-span-4">
316 <Select value={selectedUnit} onValueChange={setSelectedUnit}>
317 <SelectTrigger aria-label="File size limit unit" size="small">
318 <SelectValue>{selectedUnit}</SelectValue>
319 </SelectTrigger>
320 <SelectContent>
321 {Object.values(StorageSizeUnits).map((unit: string) => (
322 <SelectItem key={unit} value={unit} className="text-xs">
323 {unit}
324 </SelectItem>
325 ))}
326 </SelectContent>
327 </Select>
328 </div>
329 </div>
330 </FormItemLayout>
331 )}
332 />
333 {formattedSizeLimitError?.message === 'exceed_global_limit' && (
334 <FormMessage className="mt-2">
335 Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
336 <InlineLink
337 className="text-destructive decoration-destructive-500 hover:decoration-destructive"
338 href={`/project/${ref}/storage/settings`}
339 onClick={() => onOpenChange(false)}
340 >
341 Storage Settings
342 </InlineLink>{' '}
343 first.
344 </FormMessage>
345 )}
346
347 {IS_PLATFORM && (
348 <p className="text-sm text-foreground-lighter mt-2">
349 This project has a{' '}
350 <InlineLink
351 className="text-foreground-light hover:text-foreground"
352 href={`/project/${ref}/storage/settings`}
353 onClick={() => onOpenChange(false)}
354 >
355 global file size limit
356 </InlineLink>{' '}
357 of {formattedGlobalUploadLimit}.
358 </p>
359 )}
360 </div>
361 )}
362 </DialogSection>
363
364 <DialogSectionSeparator />
365
366 <DialogSection className="space-y-2">
367 <FormItemLayout
368 name="has_allowed_mime_types"
369 label="Restrict MIME types"
370 description="Allow only certain types of files to be uploaded"
371 layout="flex"
372 >
373 <FormControl>
374 <Switch
375 id="has_allowed_mime_types"
376 size="large"
377 checked={hasAllowedMimeTypes}
378 onCheckedChange={setHasAllowedMimeTypes}
379 />
380 </FormControl>
381 </FormItemLayout>
382 {hasAllowedMimeTypes && (
383 <FormField
384 key="allowed_mime_types"
385 name="allowed_mime_types"
386 control={form.control}
387 render={({ field }) => (
388 <FormItemLayout
389 name="allowed_mime_types"
390 label="Allowed MIME types"
391 labelOptional="Comma separated values"
392 description="Wildcards are allowed, e.g. image/*."
393 >
394 <FormControl>
395 <Input
396 id="allowed_mime_types"
397 {...field}
398 placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
399 />
400 </FormControl>
401 </FormItemLayout>
402 )}
403 />
404 )}
405 </DialogSection>
406 </form>
407 </Form>
408
409 <DialogFooter>
410 <Button type="default" disabled={isCreatingBucket} onClick={() => onOpenChange(false)}>
411 Cancel
412 </Button>
413 <Button
414 form={formId}
415 htmlType="submit"
416 loading={isCreatingBucket}
417 disabled={isCreatingBucket}
418 >
419 Create
420 </Button>
421 </DialogFooter>
422 </DialogContent>
423 </Dialog>
424 )
425}