| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497 |
- 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<StorageSettingsState>({
- 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<z.infer<typeof FormSchema>>({
- 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<z.infer<typeof FormSchema>> = 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 (
- <PageContainer>
- <PageSection>
- <PageSectionContent className="flex flex-col gap-y-8">
- <Form {...form}>
- {!IS_PLATFORM ? (
- <Admonition
- type="default"
- title="Storage settings are not available for self-hosted projects"
- description="Storage settings are only available for Briven Platform projects."
- />
- ) : isLoading ? (
- <GenericSkeletonLoader />
- ) : (
- <>
- {!canReadStorageSettings && (
- <NoPermission resourceText="view storage upload limit settings" />
- )}
- {isError && (
- <AlertError
- error={error}
- subject="Failed to retrieve project's storage configuration"
- />
- )}
- {isSuccess && (
- <>
- {showMigrationCallout && (
- <>
- {isListV2UpgradeAvailable && <StorageListV2MigrationCallout />}
- {isListV2Upgrading && <StorageListV2MigratingCallout />}
- </>
- )}
- <form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
- <Card>
- <CardContent>
- <FormField
- 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={`${DOCS_URL}/guides/storage/serving/image-transformations`}
- >
- Learn more
- </InlineLink>
- .
- </>
- }
- >
- <FormControl>
- <Switch
- size="large"
- disabled={
- !hasAccessToImageTransformations || !canUpdateStorageSettings
- }
- checked={hasAccessToImageTransformations && field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItemLayout>
- )}
- />
- </CardContent>
- <CardContent>
- <FormField
- control={form.control}
- name="fileSizeLimit"
- render={({ field }) => (
- <FormItemLayout
- hideMessage
- layout="flex-row-reverse"
- label="Global file size limit"
- className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full [&>div]:min-w-100"
- description={
- <>
- Restrict the size of files uploaded across all buckets.{' '}
- <InlineLink
- href={`${DOCS_URL}/guides/storage/uploads/file-limits`}
- >
- Learn more
- </InlineLink>
- .
- {!shouldAutoValidateBucketLimits && (
- <p>
- Ensure that the global limit is greater than that of
- individual buckets
- </p>
- )}
- </>
- }
- >
- <FormControl>
- <div className="flex items-center justify-end">
- <Input
- type="number"
- {...field}
- onChange={(e) => {
- field.onChange(e)
- form.clearErrors('fileSizeLimit')
- }}
- className="w-32 rounded-r-none border-r-0"
- disabled={
- !hasAccessToFileSizeConfiguration ||
- !canUpdateStorageSettings
- }
- />
- <FormField
- control={form.control}
- name="unit"
- render={({ field: unitField }) => (
- <Select
- value={unitField.value}
- onValueChange={(val) => {
- unitField.onChange(val)
- form.clearErrors('fileSizeLimit')
- }}
- disabled={
- !hasAccessToFileSizeConfiguration ||
- !canUpdateStorageSettings
- }
- >
- <SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300">
- <SelectValue placeholder="Choose a prefix">
- {storageUnit}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- {Object.values(StorageSizeUnits).map((unit: string) => (
- <SelectItem
- key={unit}
- disabled={!hasAccessToFileSizeConfiguration}
- value={unit}
- >
- {unit}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- )}
- />
- </div>
- </FormControl>
- {sizeLimitCheckCondition === 'confirm' && (
- <ValidateSizeLimit
- onValidate={sizeLimitCheckQuery}
- projectRef={projectRef}
- isLoadingBucketEstimate={isBucketEstimatePending}
- />
- )}
- </FormItemLayout>
- )}
- />
- {fileSizeLimitError && (
- <FormMessage className="ml-auto mt-2 text-right w-1/2">
- <StorageFileSizeLimitErrorMessage
- error={fileSizeLimitError}
- projectRef={projectRef}
- />
- </FormMessage>
- )}
- </CardContent>
- {hasLimitedStorageAccess && (
- <UpgradeToPro
- fullWidth
- variant="primary"
- source="storageSizeLimit"
- featureProposition="configure upload file size limits in Storage"
- 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.`}
- />
- )}
- {isSpendCapOn && (
- <UpgradeToPro
- fullWidth
- addon="spendCap"
- variant="default"
- source="storageSizeLimit"
- featureProposition="increase the file upload size limits in Storage"
- 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
- )}.`}
- />
- )}
- {!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={hasLimitedStorageAccess ? 'default' : 'primary'}
- htmlType="submit"
- loading={isUpdating}
- disabled={
- !canUpdateStorageSettings || isUpdating || !form.formState.isDirty
- }
- >
- Save
- </Button>
- </CardFooter>
- </Card>
- </form>
- </>
- )}
- </>
- )}
- </Form>
- </PageSectionContent>
- </PageSection>
- </PageContainer>
- )
- }
|