EditBucketModal.tsx 16 KB

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