EditBucketModal.tsx441 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { useParams } from 'common'
3import { useEffect, useRef, useState } from 'react'
4import { useForm, type SubmitHandler } from 'react-hook-form'
5import { toast } from 'sonner'
6import {
7 Button,
8 Dialog,
9 DialogContent,
10 DialogFooter,
11 DialogHeader,
12 DialogSection,
13 DialogSectionSeparator,
14 DialogTitle,
15 Form,
16 FormControl,
17 FormField,
18 FormMessage,
19 Input,
20 Select,
21 SelectContent,
22 SelectItem,
23 SelectTrigger,
24 SelectValue,
25 Switch,
26} from 'ui'
27import { Admonition } from 'ui-patterns'
28import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
29import { z } from 'zod'
30
31import { StorageSizeUnits } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.constants'
32import {
33 convertFromBytes,
34 convertToBytes,
35} from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
36import { InlineLink } from '@/components/ui/InlineLink'
37import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
38import { useBucketUpdateMutation } from '@/data/storage/bucket-update-mutation'
39import { Bucket } from '@/data/storage/buckets-query'
40import { DOCS_URL, IS_PLATFORM } from '@/lib/constants'
41
42export interface EditBucketModalProps {
43 visible: boolean
44 bucket: Bucket
45 onClose: () => void
46}
47
48const BucketSchema = z.object({
49 name: z.string(),
50 public: z.boolean().default(false),
51 has_file_size_limit: z.boolean().default(false),
52 formatted_size_limit: z.coerce
53 .number()
54 .min(0, 'File size upload limit has to be at least 0')
55 .optional(),
56 allowed_mime_types: z.string().trim().default(''),
57})
58
59const formId = 'edit-storage-bucket-form'
60
61export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
62 const { ref } = useParams()
63
64 const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
65 const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
66 const formattedGlobalUploadLimit = `${value} ${unit}`
67
68 const bucketIdRef = useRef<string | null>(null)
69 const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
70 const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0)
71
72 const { mutate: updateBucket, isPending: isUpdating } = useBucketUpdateMutation({
73 onSuccess: () => {
74 toast.success(`Successfully updated bucket "${bucket?.name}"`)
75 onClose()
76 },
77 onError: (error) => {
78 // Handle specific error cases for inline display
79 const errorMessage = error.message?.toLowerCase() || ''
80
81 if (
82 errorMessage.includes('exceeded the maximum allowed size') ||
83 errorMessage.includes('maximum allowed size') ||
84 errorMessage.includes('entity too large') ||
85 errorMessage.includes('payload too large')
86 ) {
87 // Set form error for the file size limit field
88 form.setError('formatted_size_limit', {
89 type: 'manual',
90 message: `Exceeds global limit of ${formattedGlobalUploadLimit}.`,
91 })
92 } else if (
93 errorMessage.includes('mime type') &&
94 (errorMessage.includes('is not supported') || errorMessage.includes('not supported'))
95 ) {
96 // Set form error for the MIME types field
97 form.setError('allowed_mime_types', {
98 type: 'manual',
99 message: 'Invalid MIME type format. Please check your input.',
100 })
101 } else {
102 // For other errors, show a toast as fallback
103 toast.error(`Failed to update bucket: ${error.message || 'Unknown error'}`)
104 }
105 },
106 })
107
108 const defaultValues = {
109 name: bucket?.name ?? '',
110 public: bucket?.public,
111 has_file_size_limit: Boolean(bucket?.file_size_limit),
112 formatted_size_limit: bucket?.file_size_limit ? (fileSizeLimit ?? 0) : undefined,
113 allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
114 }
115
116 const form = useForm<z.infer<typeof BucketSchema>>({
117 resolver: zodResolver(BucketSchema as any),
118 defaultValues,
119 values: defaultValues,
120 mode: 'onSubmit',
121 })
122 const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors
123
124 const isPublicBucket = form.watch('public')
125 const hasFileSizeLimit = form.watch('has_file_size_limit')
126 const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(
127 Boolean(bucket?.allowed_mime_types?.length)
128 )
129
130 const isChangingBucketVisibility = bucket?.public !== isPublicBucket
131 const isMakingBucketPrivate = bucket?.public && !isPublicBucket
132 const isMakingBucketPublic = !bucket?.public && isPublicBucket
133
134 const closeModal = () => {
135 form.reset()
136 onClose()
137 }
138
139 const onSubmit: SubmitHandler<z.infer<typeof BucketSchema>> = async (values) => {
140 if (bucket === undefined) return console.error('Bucket is required')
141 if (ref === undefined) return console.error('Project ref is required')
142
143 // Client-side validation: Check if bucket limit exceeds global limit
144 // [Joshen] Should shift this into superRefine in the form schema
145 if (
146 values.has_file_size_limit &&
147 values.formatted_size_limit !== undefined &&
148 data?.fileSizeLimit
149 ) {
150 const bucketLimitInBytes = convertToBytes(
151 values.formatted_size_limit,
152 selectedUnit as StorageSizeUnits
153 )
154
155 if (bucketLimitInBytes > data.fileSizeLimit) {
156 return form.setError('formatted_size_limit', {
157 type: 'manual',
158 message: 'exceed_global_limit',
159 })
160 }
161 }
162
163 updateBucket({
164 projectRef: ref,
165 id: bucket.id,
166 isPublic: values.public,
167 file_size_limit:
168 values.has_file_size_limit && values.formatted_size_limit
169 ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
170 : null,
171 allowed_mime_types: hasAllowedMimeTypes
172 ? values.allowed_mime_types.length > 0
173 ? values.allowed_mime_types.split(',').map((x: string) => x.trim())
174 : null
175 : null,
176 })
177 }
178
179 useEffect(() => {
180 if (visible && bucket) {
181 // Only set the selectedUnit when the bucket changes (different bucket ID)
182 // This preserves the user's unit selection when reopening the modal for the same bucket
183 if (bucketIdRef.current !== bucket.id && bucket.file_size_limit) {
184 const { unit } = convertFromBytes(bucket.file_size_limit)
185 setSelectedUnit(unit)
186 bucketIdRef.current = bucket.id
187 }
188 }
189 }, [visible, bucket, form])
190
191 return (
192 <Dialog
193 open={visible}
194 onOpenChange={(open) => {
195 if (!open) closeModal()
196 }}
197 >
198 <DialogContent>
199 <DialogHeader>
200 <DialogTitle>{`Edit bucket “${bucket?.name}”`}</DialogTitle>
201 </DialogHeader>
202
203 <DialogSectionSeparator />
204
205 <Form {...form}>
206 <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
207 <DialogSection className="space-y-6">
208 <FormField
209 key="name"
210 name="name"
211 control={form.control}
212 render={({ field }) => (
213 <FormItemLayout
214 hideMessage
215 name="name"
216 label="Bucket name"
217 labelOptional="Cannot be changed after creation"
218 >
219 <FormControl>
220 <Input id="name" {...field} disabled />
221 </FormControl>
222 </FormItemLayout>
223 )}
224 />
225
226 <div className="flex flex-col gap-y-3">
227 <FormField
228 key="public"
229 name="public"
230 control={form.control}
231 render={({ field }) => (
232 <FormItemLayout
233 hideMessage
234 name="public"
235 label="Public bucket"
236 description="Allow anyone to read objects without authorization"
237 layout="flex"
238 >
239 <FormControl>
240 <Switch
241 id="public"
242 size="large"
243 checked={field.value}
244 onCheckedChange={field.onChange}
245 />
246 </FormControl>
247 </FormItemLayout>
248 )}
249 />
250
251 {isChangingBucketVisibility && (
252 <Admonition
253 type="warning"
254 title={`Warning: Making bucket ${isMakingBucketPublic ? 'public' : 'private'}`}
255 description={
256 <>
257 {isMakingBucketPublic && (
258 <p>This will make all objects in your bucket publicly accessible.</p>
259 )}
260
261 {isMakingBucketPrivate && (
262 <>
263 <p className="mb-2 leading-normal!">
264 All objects in your bucket will only accessible via signed URLs, or
265 downloaded with the right authorization headers.
266 </p>
267 <p className="leading-normal!">
268 Assets cached in the CDN may still be publicly accessible. You can
269 consider{' '}
270 <InlineLink
271 href={`${DOCS_URL}/guides/storage/cdn/smart-cdn#cache-eviction`}
272 >
273 purging the cache
274 </InlineLink>{' '}
275 or moving your assets to a new bucket.
276 </p>
277 </>
278 )}
279 </>
280 }
281 />
282 )}
283 </div>
284 </DialogSection>
285
286 <DialogSectionSeparator />
287
288 <DialogSection className="space-y-2">
289 <FormField
290 key="has_file_size_limit"
291 name="has_file_size_limit"
292 control={form.control}
293 render={({ field }) => (
294 <FormItemLayout
295 name="has_file_size_limit"
296 label="Restrict file size"
297 description="Prevent uploading of files larger than a specified limit"
298 layout="flex"
299 >
300 <FormControl>
301 <Switch
302 id="has_file_size_limit"
303 size="large"
304 checked={field.value}
305 onCheckedChange={field.onChange}
306 />
307 </FormControl>
308 </FormItemLayout>
309 )}
310 />
311 {hasFileSizeLimit && (
312 <div>
313 <FormField
314 key="formatted_size_limit"
315 name="formatted_size_limit"
316 control={form.control}
317 render={({ field }) => (
318 <FormItemLayout
319 hideMessage
320 name="formatted_size_limit"
321 label="File size limit"
322 >
323 <div className="grid grid-cols-12 gap-x-2">
324 <div className="col-span-8">
325 <FormControl>
326 <Input
327 id="formatted_size_limit"
328 aria-label="File size limit"
329 type="number"
330 min={0}
331 placeholder="0"
332 {...field}
333 />
334 </FormControl>
335 </div>
336 <div className="col-span-4">
337 <Select value={selectedUnit} onValueChange={setSelectedUnit}>
338 <SelectTrigger aria-label="File size limit unit" size="small">
339 <SelectValue>{selectedUnit}</SelectValue>
340 </SelectTrigger>
341 <SelectContent>
342 {Object.values(StorageSizeUnits).map((unit: string) => (
343 <SelectItem key={unit} value={unit} className="text-xs">
344 {unit}
345 </SelectItem>
346 ))}
347 </SelectContent>
348 </Select>
349 </div>
350 </div>
351 </FormItemLayout>
352 )}
353 />
354 {formattedSizeLimitError?.message === 'exceed_global_limit' && (
355 <FormMessage className="mt-2">
356 Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
357 <InlineLink
358 className="text-destructive decoration-destructive-500 hover:decoration-destructive"
359 href={`/project/${ref}/storage/settings`}
360 onClick={onClose}
361 >
362 Storage Settings
363 </InlineLink>{' '}
364 first.
365 </FormMessage>
366 )}
367
368 {IS_PLATFORM && (
369 <p className="text-sm text-foreground-lighter mt-2">
370 This project has a{' '}
371 <InlineLink
372 className="text-foreground-light hover:text-foreground"
373 href={`/project/${ref}/storage/settings`}
374 onClick={onClose}
375 >
376 global file size limit
377 </InlineLink>{' '}
378 of {formattedGlobalUploadLimit}.
379 </p>
380 )}
381 </div>
382 )}
383 </DialogSection>
384
385 <DialogSectionSeparator />
386
387 <DialogSection className="space-y-2">
388 <FormItemLayout
389 name="has_allowed_mime_types"
390 label="Restrict MIME types"
391 description="Allow only certain types of files to be uploaded"
392 layout="flex"
393 >
394 <FormControl>
395 <Switch
396 id="has_allowed_mime_types"
397 size="large"
398 checked={hasAllowedMimeTypes}
399 onCheckedChange={setHasAllowedMimeTypes}
400 />
401 </FormControl>
402 </FormItemLayout>
403 {hasAllowedMimeTypes && (
404 <FormField
405 key="allowed_mime_types"
406 name="allowed_mime_types"
407 control={form.control}
408 render={({ field }) => (
409 <FormItemLayout
410 name="allowed_mime_types"
411 label="Allowed MIME types"
412 labelOptional="Comma separated values"
413 description="Wildcards are allowed, e.g. image/*."
414 >
415 <FormControl>
416 <Input
417 id="allowed_mime_types"
418 {...field}
419 placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
420 />
421 </FormControl>
422 </FormItemLayout>
423 )}
424 />
425 )}
426 </DialogSection>
427 </form>
428 </Form>
429
430 <DialogFooter>
431 <Button type="default" disabled={isUpdating} onClick={closeModal}>
432 Cancel
433 </Button>
434 <Button form={formId} htmlType="submit" loading={isUpdating}>
435 Save
436 </Button>
437 </DialogFooter>
438 </DialogContent>
439 </Dialog>
440 )
441}