import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { snakeCase } from 'lodash' import { Plus } 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 { useParams } from 'common' import { useIcebergWrapperExtension } from 'components/interfaces/Storage/AnalyticBucketDetails/useIcebergWrapper' import { StorageSizeUnits } from 'components/interfaces/Storage/StorageSettings/StorageSettings.constants' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { InlineLink } from 'components/ui/InlineLink' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation' import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, DialogTrigger, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, FormMessage_Shadcn_, Input_Shadcn_, Label_Shadcn_, RadioGroupStacked, RadioGroupStackedItem, Select_Shadcn_, SelectContent_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, Switch, WarningIcon, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils' import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils' const FormSchema = z .object({ name: z .string() .trim() .min(1, 'Please provide a name for your bucket') .max(100, 'Bucket name should be below 100 characters') .refine( (value) => !value.endsWith(' '), 'The name of the bucket cannot end with a whitespace' ) .refine( (value) => value !== 'public', '"public" is a reserved name. Please choose another name' ), type: z.enum(['STANDARD', 'ANALYTICS']).default('STANDARD'), public: z.boolean().default(false), has_file_size_limit: z.boolean().default(false), formatted_size_limit: z.coerce .number() .min(0, 'File size upload limit has to be at least 0') .optional(), allowed_mime_types: z.string().trim().default(''), }) .superRefine((data, ctx) => { if (!validBucketNameRegex.test(data.name)) { const [match] = data.name.match(inverseValidBucketNameRegex) ?? [] ctx.addIssue({ path: ['name'], code: z.ZodIssueCode.custom, message: !!match ? `Bucket name cannot contain the "${match}" character` : 'Bucket name contains an invalid special character', }) } }) const formId = 'create-storage-bucket-form' export type CreateBucketForm = z.infer interface CreateBucketModalProps { buttonSize?: 'tiny' | 'small' buttonType?: 'default' | 'primary' buttonClassName?: string label?: string } export const CreateBucketModal = ({ buttonSize = 'tiny', buttonType = 'default', buttonClassName, label = 'New bucket', }: CreateBucketModalProps) => { const router = useRouter() const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() const isStorageV2 = useIsNewStorageUIEnabled() const [visible, setVisible] = useState(false) const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB) const { can: canCreateBuckets } = useAsyncCheckPermissions(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: () => {}, }) const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } = useIcebergWrapperCreateMutation() const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM }) const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0) const formattedGlobalUploadLimit = `${value} ${unit}` const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { name: '', public: false, type: 'STANDARD', has_file_size_limit: false, 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 [hasAllowedMimeTypes, setHasAllowedMimeTypes] = useState(false) const icebergWrapperExtensionState = useIcebergWrapperExtension() const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled const onSubmit: SubmitHandler = async (values) => { if (!ref) return console.error('Project ref is required') if (values.type === 'ANALYTICS' && !icebergCatalogEnabled) { return toast.error( 'The Analytics catalog feature is not enabled for your project. Please contact support to enable it.' ) } // [Joshen] Should shift this into superRefine in the form schema try { const fileSizeLimit = values.has_file_size_limit && values.formatted_size_limit !== undefined ? convertToBytes(values.formatted_size_limit, selectedUnit as StorageSizeUnits) : undefined const allowedMimeTypes = 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, type: values.type, isPublic: values.public, file_size_limit: fileSizeLimit, allowed_mime_types: allowedMimeTypes, }) sendEvent({ action: 'storage_bucket_created', properties: { bucketType: values.type }, groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') { await createIcebergWrapper({ bucketName: values.name }) } toast.success(`Successfully created bucket ${values.name}`) form.reset() setSelectedUnit(StorageSizeUnits.MB) setVisible(false) if (!isStorageV2) router.push(`/project/${ref}/storage/buckets/${values.name}`) } 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.MB) setVisible(false) } return ( { if (!open) { handleClose() } }} > } disabled={!canCreateBuckets} style={{ justifyContent: 'start' }} onClick={() => setVisible(true)} tooltip={{ content: { side: 'bottom', text: !canCreateBuckets ? 'You need additional permissions to create buckets' : undefined, }, }} > {label} Create storage bucket
( )} /> {!isStorageV2 && ( ( field.onChange(v)} > {IS_PLATFORM && ( <>

Stores Iceberg files and is optimized for analytical workloads.

{icebergCatalogEnabled ? null : (
This feature is currently in alpha and not yet enabled for your project. Sign up{' '} here .
)}
)}
)} /> )}
{isStandardBucket ? ( <> ( )} /> {isPublicBucket && ( )} ( )} /> {hasFileSizeLimit && (
(
{selectedUnit} {Object.values(StorageSizeUnits).map((unit: string) => ( {unit} ))}
)} /> {formattedSizeLimitError?.message === 'exceed_global_limit' && ( Exceeds global limit of {formattedGlobalUploadLimit}. Increase limit in{' '} setVisible(false)} > Storage Settings {' '} first. )} {IS_PLATFORM && (

This project has a{' '} setVisible(false)} > global file size limit {' '} of {formattedGlobalUploadLimit}.

)}
)}
{hasAllowedMimeTypes && ( ( )} /> )} ) : ( <> {icebergWrapperExtensionState === 'installed' ? (

Supabase will setup a foreign data wrapper {bucketName && {`${bucketName}_fdw`}} {' '} for easier access to the data. This action will also create{' '} S3 Access Keys {bucketName && ( <> {' '} named {`${bucketName}_keys`} )} and four Vault Secrets {bucketName && ( <> {' '} prefixed with{' '} {`${bucketName}_vault_`} )} .

As a final step, you'll need to create an{' '} Iceberg namespace before you connect the Iceberg data to your database.

) : ( You need to install the Iceberg wrapper extension to connect your Analytic bucket to your database.

You need to install the wrappers{' '} extension (with the minimum version of 0.5.3) if you want to connect your Analytics bucket to your database.

)} )}
) }