import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM, useFlag, useParams } from 'common' import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import { Button, Card, CardContent, CardFooter, Form, FormControl, FormField, FormMessage, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage' import { StorageListV2MigratingCallout, StorageListV2MigrationCallout, } from './StorageListV2MigrationCallout' import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, StorageSizeUnits, } from './StorageSettings.constants' import { convertFromBytes, convertToBytes, encodeBucketLimitErrorMessage, } from './StorageSettings.utils' import { ValidateSizeLimit } from './StorageSettings.ValidateSizeLimit' import AlertError from '@/components/ui/AlertError' import { InlineLink } from '@/components/ui/InlineLink' import NoPermission from '@/components/ui/NoPermission' 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 { useLargestBucketSizeLimitsCheck } from '@/data/storage/buckets-max-size-limit-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' import { formatBytes } from '@/lib/helpers' const formId = 'storage-settings-form' interface StorageSettingsState { fileSizeLimit: number unit: StorageSizeUnits imageTransformationEnabled: boolean } export const StorageSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() const showMigrationCallout = useFlag('storageMigrationCallout') const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_READ, '*' ) const { can: canUpdateStorageSettings } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_WRITE, '*' ) const { data: config, error, isPending: isLoadingProjectStorageConfig, isSuccess, isError, } = useProjectStorageConfigQuery({ projectRef }) const isListV2UpgradeAvailable = !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main' const isListV2Upgrading = !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'canary' const { runCondition: sizeLimitCheckCondition, runQuery: sizeLimitCheckQuery, isEstimatePending: isBucketEstimatePending, } = useLargestBucketSizeLimitsCheck({ projectRef, connectionString: project?.connectionString ?? undefined, }) const shouldAutoValidateBucketLimits = sizeLimitCheckCondition === 'auto' const { data: organization } = useSelectedOrganizationQuery() const { getEntitlementNumericValue, isEntitlementUnlimited, isLoading: isLoadingMaxFileSizeEntitlement, } = useCheckEntitlements('storage.max_file_size') const { hasAccess: hasAccessToFileSizeConfiguration, isLoading: isLoadingFileSizeConfigurable } = useCheckEntitlements('storage.max_file_size.configurable') const { hasAccess: hasAccessToImageTransformations, isLoading: isLoadingImageTransformationEntitlement, } = useCheckEntitlements('storage.image_transformations') const isSpendCapOn = organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false const hasLimitedStorageAccess = !hasAccessToImageTransformations && !hasAccessToFileSizeConfiguration const [isUpdating, setIsUpdating] = useState(false) const [initialValues, setInitialValues] = useState({ fileSizeLimit: 0, unit: StorageSizeUnits.MB, imageTransformationEnabled: false, }) const maxBytes = useMemo(() => { if (organization?.usage_billing_enabled || isEntitlementUnlimited()) { return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED } else { return getEntitlementNumericValue() ?? STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED } }, [organization, isEntitlementUnlimited, getEntitlementNumericValue]) const isLoading = isLoadingProjectStorageConfig || isLoadingPermissions || isLoadingMaxFileSizeEntitlement || isLoadingFileSizeConfigurable || isLoadingImageTransformationEntitlement const FormSchema = z .object({ fileSizeLimit: z.coerce.number(), unit: z.nativeEnum(StorageSizeUnits), imageTransformationEnabled: z.boolean(), }) .superRefine((data, ctx) => { const { unit, fileSizeLimit } = data const { value: formattedMaxLimit } = convertFromBytes(maxBytes, unit) if (fileSizeLimit > formattedMaxLimit) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Maximum limit is up to ${formattedMaxLimit.toLocaleString()} ${unit}.`, path: ['fileSizeLimit'], }) } }) const form = useForm>({ resolver: zodResolver(FormSchema as any), defaultValues: initialValues, mode: 'onSubmit', reValidateMode: 'onSubmit', }) const { unit: storageUnit } = form.watch() const fileSizeLimitError = form.formState.errors.fileSizeLimit const { mutate: updateStorageConfig } = useProjectStorageConfigUpdateUpdateMutation({ onSuccess: () => { toast.success('Successfully updated storage settings') setIsUpdating(false) }, onError: (error) => { toast.error(`Failed to update storage settings: ${error.message}`) setIsUpdating(false) }, }) const onSubmit: SubmitHandler> = async (data) => { if (!projectRef) return console.error('Project ref is required') if (!config) return console.error('Storage config is required') setIsUpdating(true) if (shouldAutoValidateBucketLimits) { try { const buckets = await sizeLimitCheckQuery() const globalLimitInBytes = convertToBytes(data.fileSizeLimit, data.unit) const failingBuckets = buckets.filter( (bucket) => bucket.file_size_limit > globalLimitInBytes ) if (failingBuckets.length > 0) { setIsUpdating(false) form.setError('fileSizeLimit', { type: 'manual', message: encodeBucketLimitErrorMessage( failingBuckets.map((bucket) => ({ name: bucket.name, limit: bucket.file_size_limit, })) ), }) return } form.clearErrors('fileSizeLimit') } catch (error) { setIsUpdating(false) const message = error instanceof Error && error.message.length > 0 ? error.message : 'Unexpected error' form.setError('fileSizeLimit', { type: 'manual', message: `Failed to validate bucket limits automatically: ${message}`, }) return } } updateStorageConfig({ projectRef, fileSizeLimit: convertToBytes(data.fileSizeLimit, data.unit), features: { imageTransformation: { enabled: data.imageTransformationEnabled }, s3Protocol: { enabled: config.features.s3Protocol.enabled }, }, }) } useEffect(() => { if (isSuccess && config && !isLoading) { const { fileSizeLimit, features } = config const { value, unit } = convertFromBytes(fileSizeLimit ?? 0) const imageTransformationEnabled = features?.imageTransformation?.enabled ?? hasAccessToImageTransformations setInitialValues({ fileSizeLimit: value, unit: unit, imageTransformationEnabled, }) // Reset the form values when the config values load form.reset({ fileSizeLimit: value, unit: unit, imageTransformationEnabled, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, config, isLoading, hasAccessToImageTransformations]) return (
{!IS_PLATFORM ? ( ) : isLoading ? ( ) : ( <> {!canReadStorageSettings && ( )} {isError && ( )} {isSuccess && ( <> {showMigrationCallout && ( <> {isListV2UpgradeAvailable && } {isListV2Upgrading && } )} ( Optimize and resize images on the fly.{' '} Learn more . } > )} /> ( Restrict the size of files uploaded across all buckets.{' '} Learn more . {!shouldAutoValidateBucketLimits && (

Ensure that the global limit is greater than that of individual buckets

)} } >
{ field.onChange(e) form.clearErrors('fileSizeLimit') }} className="w-32 rounded-r-none border-r-0" disabled={ !hasAccessToFileSizeConfiguration || !canUpdateStorageSettings } /> ( )} />
{sizeLimitCheckCondition === 'confirm' && ( )}
)} /> {fileSizeLimitError && ( )}
{hasLimitedStorageAccess && ( )} {isSpendCapOn && ( )} {!canUpdateStorageSettings && (

You need additional permissions to update storage settings

)} {form.formState.isDirty && ( )}
)} )}
) }