mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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'
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user