feat: clearer upload limits (#38272)

* feat: basic clarity improvements

* feat: all switches

* feat: basic errors

* fix: simplify

* fix: properly separate sections

* fix: remove smart toggle closure

* fix: remember file size unit

* fix: validation

* fix: UpgradeToPro styles

* fix: UpgradeToPro styles for full-width

* feat: create modal match

* fix: tests

* Small fix for error message and desc for file size limit in create and edit bucket modals

* Prettify error message handling for storage settings

* Nit

* set default unit as MB

* dotted underline and default cursor for tooltip

* fix: default MB tests

* minor form style and copy improvements

* remove extraneous divs

* fix mime check on bucket creation

* Clean up + minor refactors including improving perms loading state in storage settings

* EditBucketModal ensure that file size limit unit defaults to MB if bucket doesnt have a file size limit

* Refactor EditBucketModal to use react query mutation

* Refactor

* Fix unit tests for edit bucket modal

* last round of clean up

* last fix

* Tiny unrelated fix

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2025-09-03 12:05:34 +10:00
1 parent fa571ed2cb
commit bd57a73521
18 files changed
+875 -660

No files matched your search

@@ -28,7 +28,7 @@ export interface BucketRowProps {
isSelected: boolean
}
const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => {
export const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowProps) => {
const { can: canUpdateBuckets } = useAsyncCheckProjectPermissions(
PermissionAction.STORAGE_WRITE,
'*'
@@ -118,5 +118,3 @@ const BucketRow = ({ bucket, projectRef = '', isSelected = false }: BucketRowPro
</div>
)
}
export default BucketRow
@@ -1,14 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { snakeCase } from 'lodash'
import { ChevronDown, Edit } from 'lucide-react'
import Link from 'next/link'
import { Edit } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
@@ -26,10 +25,6 @@ import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
cn,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
Dialog,
DialogContent,
DialogFooter,
@@ -41,6 +36,7 @@ import {
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
RadioGroupStacked,
@@ -79,7 +75,7 @@ const FormSchema = z
formatted_size_limit: z.coerce
.number()
.min(0, 'File size upload limit has to be at least 0')
.default(0),
.optional(),
allowed_mime_types: z.string().trim().default(''),
})
.superRefine((data, ctx) => {
@@ -99,17 +95,20 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer<typeof FormSchema>
const CreateBucketModal = () => {
const [visible, setVisible] = useState(false)
export const CreateBucketModal = () => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
const router = useRouter()
const [visible, setVisible] = useState(false)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
const { can: canCreateBuckets } = useAsyncCheckProjectPermissions(
PermissionAction.STORAGE_WRITE,
'*'
)
const { mutate: sendEvent } = useSendEventMutation()
const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation({
// [Joshen] Silencing the error here as it's being handled in onSubmit
onError: () => {},
@@ -121,9 +120,6 @@ const CreateBucketModal = () => {
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
const [showConfiguration, setShowConfiguration] = useState(false)
const form = useForm<CreateBucketForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
@@ -131,16 +127,17 @@ const CreateBucketModal = () => {
public: false,
type: 'STANDARD',
has_file_size_limit: false,
formatted_size_limit: 0,
formatted_size_limit: undefined,
allowed_mime_types: '',
},
})
const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors
const bucketName = snakeCase(form.watch('name'))
const isPublicBucket = form.watch('public')
const isStandardBucket = form.watch('type') === 'STANDARD'
const hasFileSizeLimit = form.watch('has_file_size_limit')
const formattedSizeLimit = form.watch('formatted_size_limit')
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false)
const icebergWrapperExtensionState = useIcebergWrapperExtension()
const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
@@ -148,22 +145,30 @@ const CreateBucketModal = () => {
if (!ref) return console.error('Project ref is required')
if (values.type === 'ANALYTICS' && !icebergCatalogEnabled) {
toast.error(
return toast.error(
'The Analytics catalog feature is not enabled for your project. Please contact support to enable it.'
)
return
}
// [Joshen] Should shift this into superRefine in the form schema
try {
const fileSizeLimit = values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
: undefined
const fileSizeLimit =
values.has_file_size_limit && values.formatted_size_limit !== undefined
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
: undefined
const allowedMimeTypes =
values.allowed_mime_types.length > 0
hasAllowedMimeTypes && values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x) => x.trim())
: undefined
if (!!fileSizeLimit && !!data?.fileSizeLimit && fileSizeLimit > data.fileSizeLimit) {
return form.setError('formatted_size_limit', {
type: 'manual',
message: 'exceed_global_limit',
})
}
await createBucket({
projectRef: ref,
id: values.name,
@@ -181,22 +186,36 @@ const CreateBucketModal = () => {
if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') {
await createIcebergWrapper({ bucketName: values.name })
}
form.reset()
setSelectedUnit(StorageSizeUnits.BYTES)
setShowConfiguration(false)
setVisible(false)
toast.success(`Successfully created bucket ${values.name}`)
form.reset()
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
router.push(`/project/${ref}/storage/buckets/${values.name}`)
} catch (error) {
console.error(error)
toast.error('Failed to create bucket')
} catch (error: any) {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
if (
errorMessage.includes('mime type') &&
(errorMessage.includes('is not supported') || errorMessage.includes('not supported'))
) {
// Set form error for the MIME types field
form.setError('allowed_mime_types', {
type: 'manual',
message: 'Invalid MIME type format. Please check your input.',
})
} else {
// For other errors, show a toast as fallback
toast.error(`Failed to create bucket: ${error.message}`)
}
}
}
const handleClose = () => {
form.reset()
setSelectedUnit(StorageSizeUnits.BYTES)
setShowConfiguration(false)
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
}
@@ -239,7 +258,7 @@ const CreateBucketModal = () => {
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection>
<DialogSection className="flex flex-col gap-y-2">
<FormField_Shadcn_
key="name"
name="name"
@@ -316,7 +335,7 @@ const CreateBucketModal = () => {
{isStandardBucket ? (
<>
<DialogSection>
<DialogSection className="space-y-3">
<FormField_Shadcn_
key="public"
name="public"
@@ -326,7 +345,7 @@ const CreateBucketModal = () => {
hideMessage
name="public"
label="Public bucket"
description="Anyone can read any object without any authorization"
description="Allow anyone to read objects without authorization"
layout="flex"
>
<FormControl_Shadcn_>
@@ -340,154 +359,166 @@ const CreateBucketModal = () => {
</FormItemLayout>
)}
/>
{isPublicBucket && (
<Admonition
type="warning"
title="Public buckets are not protected"
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."
/>
)}
</DialogSection>
{isPublicBucket && (
<Admonition
type="warning"
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
title="Public buckets are not protected"
description={
<>
<p className="mb-2">
Users can read objects in public buckets without any authorization.
</p>
<p>
Row level security (RLS) policies are still required for other operations
such as object uploads and deletes.
</p>
</>
}
/>
)}
<DialogSectionSeparator />
<DialogSection>
<Collapsible_Shadcn_
open={showConfiguration}
onOpenChange={() => setShowConfiguration(!showConfiguration)}
>
<CollapsibleTrigger_Shadcn_ asChild>
<button className="w-full cursor-pointer flex items-center justify-between">
<p className="text-sm">Additional configuration</p>
<ChevronDown
size={18}
strokeWidth={2}
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
/>
</button>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="pt-4 space-y-4">
<div className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file upload size for bucket"
description="Prevent uploading of file sizes greater than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="formatted_size_limit"
description={`Equivalent to ${convertToBytes(
formattedSizeLimit,
selectedUnit as StorageSizeUnits
).toLocaleString()} bytes.`}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
{...field}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
className="col-span-4"
>
<SelectValue_Shadcn_ asChild>
<>{selectedUnit}</>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
<div>{unit}</div>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{IS_PLATFORM && (
<div className="col-span-12">
<p className="text-foreground-light text-sm">
Note: Individual bucket uploads will still be capped at the{' '}
<Link
href={`/project/${ref}/settings/storage`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
)}
</div>
<DialogSection className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div>
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
hideMessage
name="formatted_size_limit"
label="File size limit"
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl_Shadcn_>
</div>
<div className="col-span-4">
<Select_Shadcn_
value={selectedUnit}
onValueChange={setSelectedUnit}
>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
>
<SelectValue_Shadcn_>{selectedUnit}</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_
key={unit}
value={unit}
className="text-xs"
>
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
</div>
</FormItemLayout>
)}
/>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage_Shadcn_ className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage_Shadcn_>
)}
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={() => setVisible(false)}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormItemLayout
name="has_allowed_mime_types"
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_allowed_mime_types"
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
/>
</FormControl_Shadcn_>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</DialogSection>
</>
) : (
@@ -586,5 +617,3 @@ const CreateBucketModal = () => {
</Dialog>
)
}
export default CreateBucketModal
@@ -1,36 +1,10 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
CollapsibleContent_Shadcn_,
CollapsibleTrigger_Shadcn_,
Collapsible_Shadcn_,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
Switch,
cn,
} from 'ui'
import { z } from 'zod'
import { useParams } from 'common'
import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants'
import {
convertFromBytes,
@@ -41,7 +15,27 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config
import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation'
import { Bucket } from 'data/storage/buckets-query'
import { IS_PLATFORM } from 'lib/constants'
import { isNonNullable } from 'lib/isNonNullable'
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Select_Shadcn_,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -58,7 +52,7 @@ const BucketSchema = z.object({
formatted_size_limit: z.coerce
.number()
.min(0, 'File size upload limit has to be at least 0')
.default(0),
.optional(),
allowed_mime_types: z.string().trim().default(''),
})
@@ -67,83 +61,138 @@ const formId = 'edit-storage-bucket-form'
export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) => {
const { ref } = useParams()
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation()
const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.BYTES)
const [showConfiguration, setShowConfiguration] = useState(false)
const bucketIdRef = useRef<string | null>(null)
const [selectedUnit, setSelectedUnit] = useState<string>(StorageSizeUnits.MB)
const { value: fileSizeLimit } = convertFromBytes(bucket?.file_size_limit ?? 0)
const { mutate: updateBucket, isLoading: isUpdating } = useBucketUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated bucket "${bucket?.name}"`)
onClose()
},
onError: (error) => {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
if (
errorMessage.includes('exceeded the maximum allowed size') ||
errorMessage.includes('maximum allowed size') ||
errorMessage.includes('entity too large') ||
errorMessage.includes('payload too large')
) {
// Set form error for the file size limit field
form.setError('formatted_size_limit', {
type: 'manual',
message: `Exceeds global limit of ${formattedGlobalUploadLimit}.`,
})
} else if (
errorMessage.includes('mime type') &&
(errorMessage.includes('is not supported') || errorMessage.includes('not supported'))
) {
// Set form error for the MIME types field
form.setError('allowed_mime_types', {
type: 'manual',
message: 'Invalid MIME type format. Please check your input.',
})
} else {
// For other errors, show a toast as fallback
toast.error(`Failed to update bucket: ${error.message || 'Unknown error'}`)
}
},
})
const defaultValues = {
name: bucket?.name ?? '',
public: bucket?.public,
has_file_size_limit: Boolean(bucket?.file_size_limit),
formatted_size_limit: bucket?.file_size_limit ? fileSizeLimit ?? 0 : undefined,
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
}
const form = useForm<z.infer<typeof BucketSchema>>({
resolver: zodResolver(BucketSchema),
defaultValues: {
name: bucket?.name ?? '',
public: bucket?.public,
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
formatted_size_limit: fileSizeLimit ?? 0,
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
},
values: {
name: bucket?.name ?? '',
public: bucket?.public,
has_file_size_limit: isNonNullable(bucket?.file_size_limit),
formatted_size_limit: fileSizeLimit ?? 0,
allowed_mime_types: (bucket?.allowed_mime_types ?? []).join(', '),
},
defaultValues,
values: defaultValues,
mode: 'onSubmit',
})
const { formatted_size_limit: formattedSizeLimitError } = form.formState.errors
const isPublicBucket = form.watch('public')
const hasFileSizeLimit = form.watch('has_file_size_limit')
const formattedSizeLimit = form.watch('formatted_size_limit')
const [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(
Boolean(bucket?.allowed_mime_types?.length)
)
const isChangingBucketVisibility = bucket?.public !== isPublicBucket
const isMakingBucketPrivate = bucket?.public && !isPublicBucket
const isMakingBucketPublic = !bucket?.public && isPublicBucket
const closeModal = () => {
form.reset()
onClose()
}
const onSubmit: SubmitHandler<z.infer<typeof BucketSchema>> = async (values) => {
if (bucket === undefined) return console.error('Bucket is required')
if (ref === undefined) return console.error('Project ref is required')
updateBucket(
{
projectRef: ref,
id: bucket.id,
isPublic: values.public,
file_size_limit: values.has_file_size_limit
// Client-side validation: Check if bucket limit exceeds global limit
// [Joshen] Should shift this into superRefine in the form schema
if (
values.has_file_size_limit &&
values.formatted_size_limit !== undefined &&
data?.fileSizeLimit
) {
const bucketLimitInBytes = convertToBytes(
values.formatted_size_limit,
selectedUnit as StorageSizeUnits
)
if (bucketLimitInBytes > data.fileSizeLimit) {
return form.setError('formatted_size_limit', {
type: 'manual',
message: 'exceed_global_limit',
})
}
}
updateBucket({
projectRef: ref,
id: bucket.id,
isPublic: values.public,
file_size_limit:
values.has_file_size_limit && values.formatted_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits)
: null,
allowed_mime_types:
values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
: null,
},
{
onSuccess: () => {
toast.success(`Successfully updated bucket "${bucket?.name}"`)
onClose()
},
}
)
allowed_mime_types: hasAllowedMimeTypes
? values.allowed_mime_types.length > 0
? values.allowed_mime_types.split(',').map((x: string) => x.trim())
: null
: null,
})
}
useEffect(() => {
if (visible && bucket) {
setShowConfiguration(false)
const { unit } = convertFromBytes(bucket.file_size_limit ?? 0)
setSelectedUnit(unit)
// Only set the selectedUnit when the bucket changes (different bucket ID)
// This preserves the user's unit selection when reopening the modal for the same bucket
if (bucketIdRef.current !== bucket.id && bucket.file_size_limit) {
const { unit } = convertFromBytes(bucket.file_size_limit)
setSelectedUnit(unit)
bucketIdRef.current = bucket.id
}
}
}, [visible, bucket])
}, [visible, bucket, form])
return (
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) {
form.reset()
onClose()
}
if (!open) closeModal()
}}
>
<DialogContent>
@@ -155,7 +204,7 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-y-4">
<DialogSection className="space-y-6">
<FormField_Shadcn_
key="name"
name="name"
@@ -173,20 +222,82 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
</FormItemLayout>
)}
/>
<div className="flex flex-col gap-y-3">
<FormField_Shadcn_
key="public"
name="public"
control={form.control}
render={({ field }) => (
<FormItemLayout
hideMessage
name="public"
label="Public bucket"
description="Allow anyone to read objects without authorization"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="public"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{isChangingBucketVisibility && (
<Admonition
type="warning"
title={`Warning: Making bucket ${isMakingBucketPublic ? 'public' : 'private'}`}
description={
<>
{isMakingBucketPublic && (
<p>This will make all objects in your bucket publicly accessible.</p>
)}
{isMakingBucketPrivate && (
<>
<p className="mb-2 !leading-normal">
All objects in your bucket will only accessible via signed URLs, or
downloaded with the right authorization headers.
</p>
<p className="!leading-normal">
Assets cached in the CDN may still be publicly accessible. You can
consider{' '}
<InlineLink href="https://supabase.com/docs/guides/storage/cdn/smart-cdn#cache-eviction">
purging the cache
</InlineLink>{' '}
or moving your assets to a new bucket.
</p>
</>
)}
</>
}
/>
)}
</div>
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormField_Shadcn_
key="public"
name="public"
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="public"
label="Public bucket"
description="Anyone can read any object without any authorization"
name="has_file_size_limit"
label="Restrict file size"
description="Prevent uploading of files larger than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="public"
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
@@ -195,180 +306,127 @@ export const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalPro
</FormItemLayout>
)}
/>
</DialogSection>
{isChangingBucketVisibility && (
<Admonition
type="warning"
className="rounded-none border-x-0 border-b-0 mb-0 [&>div>p]:!leading-normal"
title={`Warning: Making bucket ${isMakingBucketPublic ? 'public' : 'private'}`}
description={
<>
{isMakingBucketPublic && (
<p>This will make all objects in your bucket publicly accessible.</p>
)}
{isMakingBucketPrivate && (
<>
<p className="mb-2">
All objects in your bucket will be private and only accessible via signed
URLs, or downloaded with the right authorisation headers.
</p>
<p>
Assets cached in the CDN may still be publicly accessible. You can
consider{' '}
<InlineLink href="https://supabase.com/docs/guides/storage/cdn/smart-cdn#cache-eviction">
purging the cache
</InlineLink>{' '}
or moving your assets to a new bucket.
</p>
</>
)}
</>
}
/>
)}
<DialogSectionSeparator />
<DialogSection>
<Collapsible_Shadcn_
open={showConfiguration}
onOpenChange={() => setShowConfiguration(!showConfiguration)}
>
<CollapsibleTrigger_Shadcn_ asChild>
<button className="w-full cursor-pointer flex items-center justify-between">
<p className="text-sm">Additional configuration</p>
<ChevronDown
size={18}
strokeWidth={2}
className={cn('text-foreground-light', showConfiguration && 'rotate-180')}
/>
</button>
</CollapsibleTrigger_Shadcn_>
<CollapsibleContent_Shadcn_ className="pt-4 space-y-4">
<div className="space-y-2">
<FormField_Shadcn_
key="has_file_size_limit"
name="has_file_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="has_file_size_limit"
label="Restrict file upload size for bucket"
description="Prevent uploading of file sizes greater than a specified limit"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_file_size_limit"
size="large"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{hasFileSizeLimit && (
<div className="grid grid-cols-12 col-span-12 gap-x-2 gap-y-1">
<div className="col-span-8">
<FormField_Shadcn_
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="formatted_size_limit"
description={`Equivalent to ${convertToBytes(
formattedSizeLimit,
selectedUnit as StorageSizeUnits
).toLocaleString()} bytes.`}
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
{...field}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_
aria-label="File size limit unit"
size="small"
className="col-span-4"
>
<SelectValue_Shadcn_ asChild>
<>{selectedUnit}</>
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
<div>{unit}</div>
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{IS_PLATFORM && (
<div className="col-span-12 mt-2">
<p className="text-foreground-light text-sm">
Note: Individual bucket upload will still be capped at the{' '}
<Link
href={`/project/${ref}/settings/storage`}
className="font-bold underline"
>
global upload limit
</Link>{' '}
of {formattedGlobalUploadLimit}
</p>
</div>
)}
</div>
)}
</div>
{hasFileSizeLimit && (
<div>
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
key="formatted_size_limit"
name="formatted_size_limit"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*. Leave blank to allow any MIME type."
hideMessage
name="formatted_size_limit"
label="File size limit"
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
<div className="grid grid-cols-12 gap-x-2">
<div className="col-span-8">
<FormControl_Shadcn_>
<Input_Shadcn_
id="formatted_size_limit"
aria-label="File size limit"
type="number"
min={0}
placeholder="0"
{...field}
/>
</FormControl_Shadcn_>
</div>
<div className="col-span-4">
<Select_Shadcn_ value={selectedUnit} onValueChange={setSelectedUnit}>
<SelectTrigger_Shadcn_ aria-label="File size limit unit" size="small">
<SelectValue_Shadcn_>{selectedUnit}</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_ key={unit} value={unit} className="text-xs">
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
</div>
</FormItemLayout>
)}
/>
</CollapsibleContent_Shadcn_>
</Collapsible_Shadcn_>
{formattedSizeLimitError?.message === 'exceed_global_limit' && (
<FormMessage_Shadcn_ className="mt-2">
Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '}
<InlineLink
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
Storage Settings
</InlineLink>{' '}
first.
</FormMessage_Shadcn_>
)}
{IS_PLATFORM && (
<p className="text-sm text-foreground-lighter mt-2">
This project has a{' '}
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/project/${ref}/storage/settings`}
onClick={onClose}
>
global file size limit
</InlineLink>{' '}
of {formattedGlobalUploadLimit}.
</p>
)}
</div>
)}
</DialogSection>
<DialogSectionSeparator />
<DialogSection className="space-y-2">
<FormItemLayout
name="has_allowed_mime_types"
label="Restrict MIME types"
description="Allow only certain types of files to be uploaded"
layout="flex"
>
<FormControl_Shadcn_>
<Switch
id="has_allowed_mime_types"
size="large"
checked={hasAllowedMimeTypes}
onCheckedChange={setHasAllowedMimeTypes}
/>
</FormControl_Shadcn_>
</FormItemLayout>
{hasAllowedMimeTypes && (
<FormField_Shadcn_
key="allowed_mime_types"
name="allowed_mime_types"
control={form.control}
render={({ field }) => (
<FormItemLayout
name="allowed_mime_types"
label="Allowed MIME types"
labelOptional="Comma separated values"
description="Wildcards are allowed, e.g. image/*."
>
<FormControl_Shadcn_>
<Input_Shadcn_
id="allowed_mime_types"
{...field}
placeholder="e.g image/jpeg, image/png, audio/mpeg, video/mp4, etc"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
</DialogSection>
</form>
</Form_Shadcn_>
<DialogFooter>
<Button
type="default"
disabled={isUpdating}
onClick={() => {
form.reset()
onClose()
}}
>
<Button type="default" disabled={isUpdating} onClick={closeModal}>
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isUpdating}>
@@ -19,7 +19,7 @@ interface StorageExplorerProps {
bucket: Bucket
}
const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
export const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
const { ref } = useParams()
const storageExplorerRef = useRef(null)
const {
@@ -211,4 +211,3 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
}
StorageExplorer.displayName = 'StorageExplorer'
export default StorageExplorer
@@ -1,9 +1,9 @@
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useParams } from 'common'
import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal'
import { CreateBucketModal } from 'components/interfaces/Storage/CreateBucketModal'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
@@ -16,7 +16,7 @@ import {
InnerSideBarFilterSortDropdown,
InnerSideBarFilterSortDropdownItem,
} from 'ui-patterns/InnerSideMenu'
import BucketRow from './BucketRow'
import { BucketRow } from './BucketRow'
const StorageMenu = () => {
const router = useRouter()
@@ -22,7 +22,7 @@ import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
import StoragePoliciesEditPolicyModal from './StoragePoliciesEditPolicyModal'
import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
const StoragePolicies = () => {
export const StoragePolicies = () => {
const { data: project } = useSelectedProjectQuery()
const { ref: projectRef } = useParams()
@@ -284,5 +284,3 @@ const StoragePolicies = () => {
</div>
)
}
export default StoragePolicies
@@ -171,7 +171,7 @@ export const S3Connection = () => {
</CardContent>
{!canUpdateStorageSettings && (
<CardContent className="pt-0">
<CardContent>
<p className="text-sm text-foreground-light">
You need additional permissions to update storage settings
</p>
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -14,7 +15,8 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useBucketsQuery } from 'data/storage/buckets-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { formatBytes } from 'lib/helpers'
import {
@@ -24,6 +26,7 @@ import {
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
SelectContent_Shadcn_,
@@ -32,6 +35,9 @@ import {
SelectValue_Shadcn_,
Select_Shadcn_,
Switch,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
@@ -42,16 +48,22 @@ import {
} from './StorageSettings.constants'
import { convertFromBytes, convertToBytes } from './StorageSettings.utils'
const formId = 'storage-settings-form'
interface StorageSettingsState {
fileSizeLimit: number
unit: StorageSizeUnits
imageTransformationEnabled: boolean
}
const StorageSettings = () => {
export const StorageSettings = () => {
const { ref: projectRef } = useParams()
const canReadStorageSettings = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*')
const canUpdateStorageSettings = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
const { can: canReadStorageSettings, isLoading: isLoadingPermissions } =
useAsyncCheckProjectPermissions(PermissionAction.STORAGE_ADMIN_READ, '*')
const { can: canUpdateStorageSettings } = useAsyncCheckProjectPermissions(
PermissionAction.STORAGE_ADMIN_WRITE,
'*'
)
const {
data: config,
@@ -66,33 +78,23 @@ const StorageSettings = () => {
const isSpendCapOn =
organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false
const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef })
// Calculate the minimum file size limit from existing buckets
const minBucketFileSizeLimit = useMemo(() => {
const bucketLimits = buckets
.filter((bucket: any) => bucket.file_size_limit && bucket.file_size_limit > 0)
.map((bucket: any) => bucket.file_size_limit!)
return bucketLimits.length > 0 ? Math.min(...bucketLimits) : 0
}, [buckets])
const [initialValues, setInitialValues] = useState<StorageSettingsState>({
fileSizeLimit: 0,
unit: StorageSizeUnits.BYTES,
unit: StorageSizeUnits.MB,
imageTransformationEnabled: !isFreeTier,
})
useEffect(() => {
if (isSuccess && config) {
const { fileSizeLimit, features } = config
const { value, unit } = convertFromBytes(fileSizeLimit ?? 0)
const imageTransformationEnabled = features?.imageTransformation?.enabled ?? !isFreeTier
setInitialValues({
fileSizeLimit: value,
unit: unit,
imageTransformationEnabled,
})
// Reset the form values when the config values load
form.reset({
fileSizeLimit: value,
unit: unit,
imageTransformationEnabled,
})
}
}, [isSuccess, config])
const maxBytes = useMemo(() => {
if (organization?.plan.id === 'free') {
return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_FREE_PLAN
@@ -120,13 +122,45 @@ const StorageSettings = () => {
path: ['fileSizeLimit'],
})
}
// Validate that global limit is not smaller than any bucket's limit
if (minBucketFileSizeLimit > 0 && !isLoadingBuckets && buckets.length > 0) {
const { value: formattedMinBucketLimit } = convertFromBytes(minBucketFileSizeLimit, unit)
if (fileSizeLimit < formattedMinBucketLimit) {
// Get buckets that would be affected by this too-small global limit
const affectedBuckets = buckets
.filter((bucket) => bucket.file_size_limit && bucket.file_size_limit > 0)
.filter(
(bucket) => convertFromBytes(bucket.file_size_limit!, unit).value > fileSizeLimit
)
.sort((a, b) => (b.file_size_limit ?? 0) - (a.file_size_limit ?? 0))
if (affectedBuckets.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `bucketLimit:${affectedBuckets.map((x) => x.name).join(',')}`,
path: ['fileSizeLimit'],
})
}
}
}
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
})
const { fileSizeLimit: limit, unit: storageUnit } = form.watch()
const { unit: storageUnit } = form.watch()
const { fileSizeLimit: fileSizeLimitError } = form.formState.errors
const isBucketLimitError = !!fileSizeLimitError?.message?.startsWith('bucketLimit')
const affectedBuckets = isBucketLimitError
? (fileSizeLimitError?.message ?? '').split(':')[1].split(',')
: []
const firstAffectBucketLimit = convertFromBytes(
buckets.find((x) => x.name === affectedBuckets[0])?.file_size_limit ?? 0
)
const { mutate: updateStorageConfig, isLoading: isUpdating } =
useProjectStorageConfigUpdateUpdateMutation({
@@ -149,170 +183,261 @@ const StorageSettings = () => {
})
}
const formId = 'storage-settings-form'
useEffect(() => {
if (isSuccess && config) {
const { fileSizeLimit, features } = config
const { value, unit } = convertFromBytes(fileSizeLimit ?? 0)
const imageTransformationEnabled = features?.imageTransformation?.enabled ?? !isFreeTier
if (!canReadStorageSettings) {
return <NoPermission resourceText="view storage upload limit settings" />
}
setInitialValues({
fileSizeLimit: value,
unit: unit,
imageTransformationEnabled,
})
// Reset the form values when the config values load
form.reset({
fileSizeLimit: value,
unit: unit,
imageTransformationEnabled,
})
}
}, [isSuccess, config])
return (
<ScaffoldSection isFullWidth>
<Form_Shadcn_ {...form}>
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError error={error} subject="Failed to retrieve project's storage configuration" />
)}
{isSuccess && (
<form id={formId} className="" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={form.control}
name="fileSizeLimit"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Upload file size limit"
description={
<>
{storageUnit !== StorageSizeUnits.BYTES && (
{isLoading || isLoadingPermissions ? (
<GenericSkeletonLoader />
) : (
<>
{!canReadStorageSettings && (
<NoPermission resourceText="view storage upload limit settings" />
)}
{isError && (
<AlertError
error={error}
subject="Failed to retrieve project's storage configuration"
/>
)}
{isSuccess && (
<form id={formId} className="" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={form.control}
name="imageTransformationEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Image Transformation"
description={
<>
Equivalent to {convertToBytes(limit, storageUnit).toLocaleString()}{' '}
bytes.{' '}
Optimize and resize images on the fly.{' '}
<InlineLink href="https://supabase.com/docs/guides/storage/serving/image-transformations">
Learn more
</InlineLink>
.
</>
)}
Maximum upload file size is {formatBytes(maxBytes)}.
</>
}
>
<FormControl_Shadcn_>
<div className="flex items-center gap-2">
<Input_Shadcn_
type="number"
{...field}
className="w-full"
disabled={isFreeTier || !canUpdateStorageSettings}
/>
<FormField_Shadcn_
control={form.control}
name="unit"
render={({ field: unitField }) => (
<Select_Shadcn_
value={unitField.value}
onValueChange={unitField.onChange}
}
>
<FormControl_Shadcn_>
<Switch
size="large"
disabled={isFreeTier}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="fileSizeLimit"
render={({ field }) => (
<FormItemLayout
hideMessage
layout="flex-row-reverse"
label="Global file size limit"
description={
<>
Restrict the size of files uploaded across all buckets.{' '}
{isLoadingBuckets && (
<span className="text-foreground-light">
{' '}
Loading bucket information...
</span>
)}{' '}
<InlineLink href="https://supabase.com/docs/guides/storage/uploads/file-limits">
Learn more
</InlineLink>
.
</>
}
>
<FormControl_Shadcn_>
<div className="flex items-center gap-2">
<Input_Shadcn_
type="number"
{...field}
className="w-full"
disabled={isFreeTier || !canUpdateStorageSettings}
/>
<FormField_Shadcn_
control={form.control}
name="unit"
render={({ field: unitField }) => (
<Select_Shadcn_
value={unitField.value}
onValueChange={unitField.onChange}
disabled={isFreeTier || !canUpdateStorageSettings}
>
<SelectTrigger_Shadcn_ className="w-[180px]">
<SelectValue_Shadcn_ placeholder="Choose a prefix">
{storageUnit}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_
key={unit}
disabled={isFreeTier}
value={unit}
>
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{fileSizeLimitError && (
<FormMessage_Shadcn_ className="ml-auto mt-2 text-right w-1/2">
{isBucketLimitError ? (
<>
<p>Global limit must be greater than that of individual buckets.</p>
<p>
Remove or decrease the limit on{' '}
<InlineLink
href={`/project/${projectRef}/storage/buckets/${affectedBuckets[0]}`}
className="text-destructive decoration-destructive-500 hover:decoration-destructive"
>
<SelectTrigger_Shadcn_ className="w-[180px]">
<SelectValue_Shadcn_ placeholder="Choose a prefix">
{storageUnit}
</SelectValue_Shadcn_>
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{Object.values(StorageSizeUnits).map((unit: string) => (
<SelectItem_Shadcn_
key={unit}
disabled={isFreeTier}
value={unit}
>
{unit}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
{affectedBuckets[0]}
</InlineLink>{' '}
({firstAffectBucketLimit.value}
{firstAffectBucketLimit.unit})
{affectedBuckets.length > 1 && (
<>
{' '}
and{' '}
<Tooltip>
<TooltipTrigger asChild>
<span className="underline underline-offset-2 decoration-dotted decoration-destructive-500 hover:decoration-destructive cursor-default">
+{affectedBuckets.length - 1} other bucket
{affectedBuckets.length > 2 ? 's' : ''}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<ul>
{affectedBuckets.slice(1).map((name) => {
const bucket = buckets.find((x) => x.name === name)
const formattedLimit = convertFromBytes(
bucket?.file_size_limit ?? 0
)
return (
<li
key={name}
className="hover:underline underline-offset-2"
>
<Link
href={`/project/${projectRef}/storage/buckets/${name}`}
>
{bucket?.name} ({formattedLimit.value}
{formattedLimit.unit})
</Link>
</li>
)
})}
</ul>
</TooltipContent>
</Tooltip>{' '}
first
</>
)}
.
</p>
</>
) : (
fileSizeLimitError.message
)}
</FormMessage_Shadcn_>
)}
</CardContent>
{isFreeTier && (
<UpgradeToPro
fullWidth
primaryText="Free Plan has a fixed upload file size limit of 50 MB."
secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
)} and unlock image transformations.`}
source="storageSizeLimit"
/>
)}
{isSpendCapOn && (
<UpgradeToPro
fullWidth
buttonText="Disable Spend Cap"
primaryText="Reduced max upload file size limit due to Spend Cap"
secondaryText={`Disable your Spend Cap to allow file uploads of up to ${formatBytes(
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
)}.`}
source="storageSizeLimit"
/>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="imageTransformationEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Image Transformation"
description={
<>
Optimize and resize images on the fly.{' '}
<InlineLink href="https://supabase.com/docs/guides/storage/serving/image-transformations">
Learn more
</InlineLink>
.
</>
}
{!canUpdateStorageSettings && (
<CardContent>
<p className="text-sm text-foreground-light">
You need additional permissions to update storage settings
</p>
</CardContent>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="reset"
onClick={() => form.reset()}
disabled={
!form.formState.isDirty || !canUpdateStorageSettings || isUpdating
}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateStorageSettings || isUpdating || !form.formState.isDirty}
>
<FormControl_Shadcn_>
<Switch
size="large"
disabled={isFreeTier}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{isFreeTier && (
<CardContent className="pt-0">
<UpgradeToPro
primaryText="Free Plan has a fixed upload file size limit of 50 MB."
secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
)} and unlock image transformations.`}
source="storageSizeLimit"
/>
</CardContent>
)}
{isSpendCapOn && (
<CardContent className="pt-0">
<UpgradeToPro
buttonText="Disable Spend Cap"
primaryText="Reduced max upload file size limit due to Spend Cap"
secondaryText={`Disable your Spend Cap to allow file uploads of up to ${formatBytes(
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
)}.`}
source="storageSizeLimit"
/>
</CardContent>
)}
{!canUpdateStorageSettings && (
<CardContent className="pt-0">
<p className="text-sm text-foreground-light">
You need additional permissions to update storage settings
</p>
</CardContent>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="reset"
onClick={() => form.reset()}
disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateStorageSettings || isUpdating || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
Save
</Button>
</CardFooter>
</Card>
</form>
)}
</>
)}
</Form_Shadcn_>
</ScaffoldSection>
@@ -1,13 +1,13 @@
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/dom'
import { fireEvent, screen, waitFor } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { addAPIMock } from 'tests/lib/msw'
import { ProjectContextProvider } from 'components/layouts/ProjectLayout/ProjectContext'
import { addAPIMock } from 'tests/lib/msw'
import { render } from 'tests/helpers'
import { routerMock } from 'tests/lib/route-mock'
import CreateBucketModal from '../CreateBucketModal'
import { CreateBucketModal } from '../CreateBucketModal'
describe(`CreateBucketModal`, () => {
beforeEach(() => {
@@ -65,26 +65,26 @@ describe(`CreateBucketModal`, () => {
await userEvent.click(publicToggle)
expect(publicToggle).toBeChecked()
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
await userEvent.click(detailsTrigger)
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file size`)
expect(sizeLimitToggle).not.toBeChecked()
await userEvent.click(sizeLimitToggle)
expect(sizeLimitToggle).toBeChecked()
const sizeLimitInput = screen.getByLabelText(`File size limit`)
expect(sizeLimitInput).toHaveValue(0)
expect(sizeLimitInput).toHaveValue(null)
await userEvent.type(sizeLimitInput, `25`)
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
await userEvent.click(sizeLimitUnitSelect)
const mbOption = screen.getByRole(`option`, { name: `MB` })
await userEvent.click(mbOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
await userEvent.click(sizeLimitUnitSelect)
const bytesOption = screen.getByRole(`option`, { name: `bytes` })
await userEvent.click(bytesOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`)
expect(mimeTypeToggle).not.toBeChecked()
await userEvent.click(mimeTypeToggle)
expect(mimeTypeToggle).toBeChecked()
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
expect(mimeTypeInput).toHaveValue(``)
@@ -82,26 +82,26 @@ describe(`EditBucketModal`, () => {
await userEvent.click(publicToggle)
expect(publicToggle).toBeChecked()
const detailsTrigger = screen.getByRole(`button`, { name: `Additional configuration` })
expect(detailsTrigger).toHaveAttribute(`data-state`, `closed`)
await userEvent.click(detailsTrigger)
expect(detailsTrigger).toHaveAttribute(`data-state`, `open`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file upload size for bucket`)
const sizeLimitToggle = screen.getByLabelText(`Restrict file size`)
expect(sizeLimitToggle).not.toBeChecked()
await userEvent.click(sizeLimitToggle)
expect(sizeLimitToggle).toBeChecked()
const sizeLimitInput = screen.getByLabelText(`File size limit`)
expect(sizeLimitInput).toHaveValue(0)
expect(sizeLimitInput).toHaveValue(null)
await userEvent.type(sizeLimitInput, `25`)
const sizeLimitUnitSelect = screen.getByLabelText(`File size limit unit`)
expect(sizeLimitUnitSelect).toHaveTextContent(`bytes`)
await userEvent.click(sizeLimitUnitSelect)
const mbOption = screen.getByRole(`option`, { name: `MB` })
await userEvent.click(mbOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`MB`)
await userEvent.click(sizeLimitUnitSelect)
const mbOption = screen.getByRole(`option`, { name: `GB` })
await userEvent.click(mbOption)
expect(sizeLimitUnitSelect).toHaveTextContent(`GB`)
const mimeTypeToggle = screen.getByLabelText(`Restrict MIME types`)
expect(mimeTypeToggle).not.toBeChecked()
await userEvent.click(mimeTypeToggle)
expect(mimeTypeToggle).toBeChecked()
const mimeTypeInput = screen.getByLabelText(`Allowed MIME types`)
expect(mimeTypeInput).toHaveValue(``)
@@ -1,5 +0,0 @@
export { default as StorageExplorer } from './StorageExplorer/StorageExplorer'
export { default as StoragePolicies } from './StoragePolicies/StoragePolicies'
export { default as StorageSettings } from './StorageSettings/StorageSettings'
export { DeleteBucketModal } from './DeleteBucketModal'
+4 -2
View File
@@ -17,6 +17,7 @@ interface UpgradeToProProps {
buttonText?: string
source?: string
disabled?: boolean
fullWidth?: boolean
}
const UpgradeToPro = ({
@@ -27,6 +28,7 @@ const UpgradeToPro = ({
buttonText,
source = 'upgrade',
disabled = false,
fullWidth = false,
}: UpgradeToProProps) => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
@@ -41,8 +43,8 @@ const UpgradeToPro = ({
return (
<div
className={cn(
'block w-full rounded border border-opacity-20 py-4 px-6',
'border-overlay bg-surface-200'
'block w-full py-4 px-6 bg-surface-200',
fullWidth ? 'border-b' : 'border border-opacity-20 border-overlay rounded'
)}
>
<div className="flex gap-x-3">
@@ -6,14 +6,14 @@ import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { storageKeys } from './keys'
export type BucketCreateVariables = Omit<CreateStorageBucketBody, 'public'> & {
type BucketCreateVariables = Omit<CreateStorageBucketBody, 'public'> & {
projectRef: string
isPublic: boolean
}
type CreateStorageBucketBody = components['schemas']['CreateStorageBucketBody']
export async function createBucket({
async function createBucket({
projectRef,
id,
type,
@@ -6,13 +6,13 @@ import type { ResponseError } from 'types'
import { BucketType } from './buckets-query'
import { storageKeys } from './keys'
export type BucketDeleteVariables = {
type BucketDeleteVariables = {
projectRef: string
id: string
type: BucketType
}
export async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) {
async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
@@ -2,11 +2,11 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react
import { toast } from 'sonner'
import { components } from 'api-types'
import { handleError, patch } from 'data/fetchers'
import { patch } from 'data/fetchers'
import type { ResponseError } from 'types'
import { storageKeys } from './keys'
export type BucketUpdateVariables = {
type BucketUpdateVariables = {
projectRef: string
id: string
isPublic: boolean
@@ -23,13 +23,13 @@ type UpdateStorageBucketBody = Omit<
file_size_limit: number | null
}
export async function updateBucket({
async function updateBucket({
projectRef,
id,
isPublic,
file_size_limit,
allowed_mime_types,
}: BucketUpdateVariables) {
}: BucketUpdateVariables): Promise<BucketUpdateResult> {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
@@ -42,10 +42,15 @@ export async function updateBucket({
body: payload as any,
})
if (error) handleError(error)
return data
if (error) {
// Return the error instead of throwing it, so we can handle it gracefully
return { data: null, error }
}
return { data, error: null }
}
type BucketUpdateResult = { data: any; error: null } | { data: null; error: any }
type BucketUpdateData = Awaited<ReturnType<typeof updateBucket>>
export const useBucketUpdateMutation = ({
@@ -59,7 +64,13 @@ export const useBucketUpdateMutation = ({
const queryClient = useQueryClient()
return useMutation<BucketUpdateData, ResponseError, BucketUpdateVariables>(
(vars) => updateBucket(vars),
async (vars) => {
const result = await updateBucket(vars)
if (result.error) {
throw result.error
}
return result.data
},
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
@@ -1,8 +1,8 @@
import { useParams } from 'common'
import { StorageExplorer } from 'components/interfaces/Storage'
import { AnalyticBucketDetails } from 'components/interfaces/Storage/AnalyticBucketDetails'
import StorageBucketsError from 'components/interfaces/Storage/StorageBucketsError'
import { StorageExplorer } from 'components/interfaces/Storage/StorageExplorer/StorageExplorer'
import { useSelectedBucket } from 'components/interfaces/Storage/StorageExplorer/useSelectedBucket'
import DefaultLayout from 'components/layouts/DefaultLayout'
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
@@ -1,9 +1,9 @@
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
import { StoragePolicies } from 'components/interfaces/Storage/StoragePolicies/StoragePolicies'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { StoragePolicies } from 'components/interfaces/Storage'
import type { NextPageWithLayout } from 'types'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
import type { NextPageWithLayout } from 'types'
const StoragePoliciesPage: NextPageWithLayout = () => {
return <StoragePolicies />
@@ -1,10 +1,10 @@
import DefaultLayout from 'components/layouts/DefaultLayout'
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { StorageSettings } from 'components/interfaces/Storage'
import { S3Connection } from 'components/interfaces/Storage/StorageSettings/S3Connection'
import type { NextPageWithLayout } from 'types'
import { StorageSettings } from 'components/interfaces/Storage/StorageSettings/StorageSettings'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
import type { NextPageWithLayout } from 'types'
const StorageSettingsPage: NextPageWithLayout = () => {
return (