StorageSettings.tsx497 lines · main
| 1 | import { zodResolver } from '@hookform/resolvers/zod' |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { IS_PLATFORM, useFlag, useParams } from 'common' |
| 4 | import { useEffect, useMemo, useState } from 'react' |
| 5 | import { SubmitHandler, useForm } from 'react-hook-form' |
| 6 | import { toast } from 'sonner' |
| 7 | import { |
| 8 | Button, |
| 9 | Card, |
| 10 | CardContent, |
| 11 | CardFooter, |
| 12 | Form, |
| 13 | FormControl, |
| 14 | FormField, |
| 15 | FormMessage, |
| 16 | Input, |
| 17 | Select, |
| 18 | SelectContent, |
| 19 | SelectItem, |
| 20 | SelectTrigger, |
| 21 | SelectValue, |
| 22 | Switch, |
| 23 | } from 'ui' |
| 24 | import { Admonition } from 'ui-patterns/admonition' |
| 25 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 26 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 27 | import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' |
| 28 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 29 | import * as z from 'zod' |
| 30 | |
| 31 | import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage' |
| 32 | import { |
| 33 | StorageListV2MigratingCallout, |
| 34 | StorageListV2MigrationCallout, |
| 35 | } from './StorageListV2MigrationCallout' |
| 36 | import { |
| 37 | STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, |
| 38 | STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, |
| 39 | StorageSizeUnits, |
| 40 | } from './StorageSettings.constants' |
| 41 | import { |
| 42 | convertFromBytes, |
| 43 | convertToBytes, |
| 44 | encodeBucketLimitErrorMessage, |
| 45 | } from './StorageSettings.utils' |
| 46 | import { ValidateSizeLimit } from './StorageSettings.ValidateSizeLimit' |
| 47 | import AlertError from '@/components/ui/AlertError' |
| 48 | import { InlineLink } from '@/components/ui/InlineLink' |
| 49 | import NoPermission from '@/components/ui/NoPermission' |
| 50 | import { UpgradeToPro } from '@/components/ui/UpgradeToPro' |
| 51 | import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query' |
| 52 | import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation' |
| 53 | import { useLargestBucketSizeLimitsCheck } from '@/data/storage/buckets-max-size-limit-query' |
| 54 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 55 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 56 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 57 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 58 | import { DOCS_URL } from '@/lib/constants' |
| 59 | import { formatBytes } from '@/lib/helpers' |
| 60 | |
| 61 | const formId = 'storage-settings-form' |
| 62 | |
| 63 | interface StorageSettingsState { |
| 64 | fileSizeLimit: number |
| 65 | unit: StorageSizeUnits |
| 66 | imageTransformationEnabled: boolean |
| 67 | } |
| 68 | |
| 69 | export const StorageSettings = () => { |
| 70 | const { ref: projectRef } = useParams() |
| 71 | const { data: project } = useSelectedProjectQuery() |
| 72 | |
| 73 | const showMigrationCallout = useFlag('storageMigrationCallout') |
| 74 | |
| 75 | const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( |
| 76 | PermissionAction.STORAGE_ADMIN_READ, |
| 77 | '*' |
| 78 | ) |
| 79 | const { can: canUpdateStorageSettings } = useAsyncCheckPermissions( |
| 80 | PermissionAction.STORAGE_ADMIN_WRITE, |
| 81 | '*' |
| 82 | ) |
| 83 | |
| 84 | const { |
| 85 | data: config, |
| 86 | error, |
| 87 | isPending: isLoadingProjectStorageConfig, |
| 88 | isSuccess, |
| 89 | isError, |
| 90 | } = useProjectStorageConfigQuery({ projectRef }) |
| 91 | const isListV2UpgradeAvailable = |
| 92 | !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main' |
| 93 | const isListV2Upgrading = |
| 94 | !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'canary' |
| 95 | |
| 96 | const { |
| 97 | runCondition: sizeLimitCheckCondition, |
| 98 | runQuery: sizeLimitCheckQuery, |
| 99 | isEstimatePending: isBucketEstimatePending, |
| 100 | } = useLargestBucketSizeLimitsCheck({ |
| 101 | projectRef, |
| 102 | connectionString: project?.connectionString ?? undefined, |
| 103 | }) |
| 104 | const shouldAutoValidateBucketLimits = sizeLimitCheckCondition === 'auto' |
| 105 | |
| 106 | const { data: organization } = useSelectedOrganizationQuery() |
| 107 | const { |
| 108 | getEntitlementNumericValue, |
| 109 | isEntitlementUnlimited, |
| 110 | isLoading: isLoadingMaxFileSizeEntitlement, |
| 111 | } = useCheckEntitlements('storage.max_file_size') |
| 112 | const { hasAccess: hasAccessToFileSizeConfiguration, isLoading: isLoadingFileSizeConfigurable } = |
| 113 | useCheckEntitlements('storage.max_file_size.configurable') |
| 114 | const { |
| 115 | hasAccess: hasAccessToImageTransformations, |
| 116 | isLoading: isLoadingImageTransformationEntitlement, |
| 117 | } = useCheckEntitlements('storage.image_transformations') |
| 118 | |
| 119 | const isSpendCapOn = |
| 120 | organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false |
| 121 | const hasLimitedStorageAccess = |
| 122 | !hasAccessToImageTransformations && !hasAccessToFileSizeConfiguration |
| 123 | |
| 124 | const [isUpdating, setIsUpdating] = useState(false) |
| 125 | const [initialValues, setInitialValues] = useState<StorageSettingsState>({ |
| 126 | fileSizeLimit: 0, |
| 127 | unit: StorageSizeUnits.MB, |
| 128 | imageTransformationEnabled: false, |
| 129 | }) |
| 130 | |
| 131 | const maxBytes = useMemo(() => { |
| 132 | if (organization?.usage_billing_enabled || isEntitlementUnlimited()) { |
| 133 | return STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED |
| 134 | } else { |
| 135 | return getEntitlementNumericValue() ?? STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED |
| 136 | } |
| 137 | }, [organization, isEntitlementUnlimited, getEntitlementNumericValue]) |
| 138 | |
| 139 | const isLoading = |
| 140 | isLoadingProjectStorageConfig || |
| 141 | isLoadingPermissions || |
| 142 | isLoadingMaxFileSizeEntitlement || |
| 143 | isLoadingFileSizeConfigurable || |
| 144 | isLoadingImageTransformationEntitlement |
| 145 | const FormSchema = z |
| 146 | .object({ |
| 147 | fileSizeLimit: z.coerce.number(), |
| 148 | unit: z.nativeEnum(StorageSizeUnits), |
| 149 | imageTransformationEnabled: z.boolean(), |
| 150 | }) |
| 151 | .superRefine((data, ctx) => { |
| 152 | const { unit, fileSizeLimit } = data |
| 153 | const { value: formattedMaxLimit } = convertFromBytes(maxBytes, unit) |
| 154 | |
| 155 | if (fileSizeLimit > formattedMaxLimit) { |
| 156 | ctx.addIssue({ |
| 157 | code: z.ZodIssueCode.custom, |
| 158 | message: `Maximum limit is up to ${formattedMaxLimit.toLocaleString()} ${unit}.`, |
| 159 | path: ['fileSizeLimit'], |
| 160 | }) |
| 161 | } |
| 162 | }) |
| 163 | |
| 164 | const form = useForm<z.infer<typeof FormSchema>>({ |
| 165 | resolver: zodResolver(FormSchema as any), |
| 166 | defaultValues: initialValues, |
| 167 | mode: 'onSubmit', |
| 168 | reValidateMode: 'onSubmit', |
| 169 | }) |
| 170 | |
| 171 | const { unit: storageUnit } = form.watch() |
| 172 | const fileSizeLimitError = form.formState.errors.fileSizeLimit |
| 173 | |
| 174 | const { mutate: updateStorageConfig } = useProjectStorageConfigUpdateUpdateMutation({ |
| 175 | onSuccess: () => { |
| 176 | toast.success('Successfully updated storage settings') |
| 177 | setIsUpdating(false) |
| 178 | }, |
| 179 | onError: (error) => { |
| 180 | toast.error(`Failed to update storage settings: ${error.message}`) |
| 181 | setIsUpdating(false) |
| 182 | }, |
| 183 | }) |
| 184 | |
| 185 | const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = async (data) => { |
| 186 | if (!projectRef) return console.error('Project ref is required') |
| 187 | if (!config) return console.error('Storage config is required') |
| 188 | |
| 189 | setIsUpdating(true) |
| 190 | |
| 191 | if (shouldAutoValidateBucketLimits) { |
| 192 | try { |
| 193 | const buckets = await sizeLimitCheckQuery() |
| 194 | const globalLimitInBytes = convertToBytes(data.fileSizeLimit, data.unit) |
| 195 | const failingBuckets = buckets.filter( |
| 196 | (bucket) => bucket.file_size_limit > globalLimitInBytes |
| 197 | ) |
| 198 | |
| 199 | if (failingBuckets.length > 0) { |
| 200 | setIsUpdating(false) |
| 201 | form.setError('fileSizeLimit', { |
| 202 | type: 'manual', |
| 203 | message: encodeBucketLimitErrorMessage( |
| 204 | failingBuckets.map((bucket) => ({ |
| 205 | name: bucket.name, |
| 206 | limit: bucket.file_size_limit, |
| 207 | })) |
| 208 | ), |
| 209 | }) |
| 210 | return |
| 211 | } |
| 212 | |
| 213 | form.clearErrors('fileSizeLimit') |
| 214 | } catch (error) { |
| 215 | setIsUpdating(false) |
| 216 | const message = |
| 217 | error instanceof Error && error.message.length > 0 ? error.message : 'Unexpected error' |
| 218 | |
| 219 | form.setError('fileSizeLimit', { |
| 220 | type: 'manual', |
| 221 | message: `Failed to validate bucket limits automatically: ${message}`, |
| 222 | }) |
| 223 | return |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | updateStorageConfig({ |
| 228 | projectRef, |
| 229 | fileSizeLimit: convertToBytes(data.fileSizeLimit, data.unit), |
| 230 | features: { |
| 231 | imageTransformation: { enabled: data.imageTransformationEnabled }, |
| 232 | s3Protocol: { enabled: config.features.s3Protocol.enabled }, |
| 233 | }, |
| 234 | }) |
| 235 | } |
| 236 | |
| 237 | useEffect(() => { |
| 238 | if (isSuccess && config && !isLoading) { |
| 239 | const { fileSizeLimit, features } = config |
| 240 | const { value, unit } = convertFromBytes(fileSizeLimit ?? 0) |
| 241 | const imageTransformationEnabled = |
| 242 | features?.imageTransformation?.enabled ?? hasAccessToImageTransformations |
| 243 | |
| 244 | setInitialValues({ |
| 245 | fileSizeLimit: value, |
| 246 | unit: unit, |
| 247 | imageTransformationEnabled, |
| 248 | }) |
| 249 | |
| 250 | // Reset the form values when the config values load |
| 251 | form.reset({ |
| 252 | fileSizeLimit: value, |
| 253 | unit: unit, |
| 254 | imageTransformationEnabled, |
| 255 | }) |
| 256 | } |
| 257 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 258 | }, [isSuccess, config, isLoading, hasAccessToImageTransformations]) |
| 259 | |
| 260 | return ( |
| 261 | <PageContainer> |
| 262 | <PageSection> |
| 263 | <PageSectionContent className="flex flex-col gap-y-8"> |
| 264 | <Form {...form}> |
| 265 | {!IS_PLATFORM ? ( |
| 266 | <Admonition |
| 267 | type="default" |
| 268 | title="Storage settings are not available for self-hosted projects" |
| 269 | description="Storage settings are only available for Briven Platform projects." |
| 270 | /> |
| 271 | ) : isLoading ? ( |
| 272 | <GenericSkeletonLoader /> |
| 273 | ) : ( |
| 274 | <> |
| 275 | {!canReadStorageSettings && ( |
| 276 | <NoPermission resourceText="view storage upload limit settings" /> |
| 277 | )} |
| 278 | {isError && ( |
| 279 | <AlertError |
| 280 | error={error} |
| 281 | subject="Failed to retrieve project's storage configuration" |
| 282 | /> |
| 283 | )} |
| 284 | {isSuccess && ( |
| 285 | <> |
| 286 | {showMigrationCallout && ( |
| 287 | <> |
| 288 | {isListV2UpgradeAvailable && <StorageListV2MigrationCallout />} |
| 289 | {isListV2Upgrading && <StorageListV2MigratingCallout />} |
| 290 | </> |
| 291 | )} |
| 292 | <form id={formId} onSubmit={form.handleSubmit(onSubmit)}> |
| 293 | <Card> |
| 294 | <CardContent> |
| 295 | <FormField |
| 296 | control={form.control} |
| 297 | name="imageTransformationEnabled" |
| 298 | render={({ field }) => ( |
| 299 | <FormItemLayout |
| 300 | layout="flex-row-reverse" |
| 301 | label="Enable image transformation" |
| 302 | description={ |
| 303 | <> |
| 304 | Optimize and resize images on the fly.{' '} |
| 305 | <InlineLink |
| 306 | href={`${DOCS_URL}/guides/storage/serving/image-transformations`} |
| 307 | > |
| 308 | Learn more |
| 309 | </InlineLink> |
| 310 | . |
| 311 | </> |
| 312 | } |
| 313 | > |
| 314 | <FormControl> |
| 315 | <Switch |
| 316 | size="large" |
| 317 | disabled={ |
| 318 | !hasAccessToImageTransformations || !canUpdateStorageSettings |
| 319 | } |
| 320 | checked={hasAccessToImageTransformations && field.value} |
| 321 | onCheckedChange={field.onChange} |
| 322 | /> |
| 323 | </FormControl> |
| 324 | </FormItemLayout> |
| 325 | )} |
| 326 | /> |
| 327 | </CardContent> |
| 328 | |
| 329 | <CardContent> |
| 330 | <FormField |
| 331 | control={form.control} |
| 332 | name="fileSizeLimit" |
| 333 | render={({ field }) => ( |
| 334 | <FormItemLayout |
| 335 | hideMessage |
| 336 | layout="flex-row-reverse" |
| 337 | label="Global file size limit" |
| 338 | className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full [&>div]:min-w-100" |
| 339 | description={ |
| 340 | <> |
| 341 | Restrict the size of files uploaded across all buckets.{' '} |
| 342 | <InlineLink |
| 343 | href={`${DOCS_URL}/guides/storage/uploads/file-limits`} |
| 344 | > |
| 345 | Learn more |
| 346 | </InlineLink> |
| 347 | . |
| 348 | {!shouldAutoValidateBucketLimits && ( |
| 349 | <p> |
| 350 | Ensure that the global limit is greater than that of |
| 351 | individual buckets |
| 352 | </p> |
| 353 | )} |
| 354 | </> |
| 355 | } |
| 356 | > |
| 357 | <FormControl> |
| 358 | <div className="flex items-center justify-end"> |
| 359 | <Input |
| 360 | type="number" |
| 361 | {...field} |
| 362 | onChange={(e) => { |
| 363 | field.onChange(e) |
| 364 | form.clearErrors('fileSizeLimit') |
| 365 | }} |
| 366 | className="w-32 rounded-r-none border-r-0" |
| 367 | disabled={ |
| 368 | !hasAccessToFileSizeConfiguration || |
| 369 | !canUpdateStorageSettings |
| 370 | } |
| 371 | /> |
| 372 | <FormField |
| 373 | control={form.control} |
| 374 | name="unit" |
| 375 | render={({ field: unitField }) => ( |
| 376 | <Select |
| 377 | value={unitField.value} |
| 378 | onValueChange={(val) => { |
| 379 | unitField.onChange(val) |
| 380 | form.clearErrors('fileSizeLimit') |
| 381 | }} |
| 382 | disabled={ |
| 383 | !hasAccessToFileSizeConfiguration || |
| 384 | !canUpdateStorageSettings |
| 385 | } |
| 386 | > |
| 387 | <SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300"> |
| 388 | <SelectValue placeholder="Choose a prefix"> |
| 389 | {storageUnit} |
| 390 | </SelectValue> |
| 391 | </SelectTrigger> |
| 392 | <SelectContent> |
| 393 | {Object.values(StorageSizeUnits).map((unit: string) => ( |
| 394 | <SelectItem |
| 395 | key={unit} |
| 396 | disabled={!hasAccessToFileSizeConfiguration} |
| 397 | value={unit} |
| 398 | > |
| 399 | {unit} |
| 400 | </SelectItem> |
| 401 | ))} |
| 402 | </SelectContent> |
| 403 | </Select> |
| 404 | )} |
| 405 | /> |
| 406 | </div> |
| 407 | </FormControl> |
| 408 | {sizeLimitCheckCondition === 'confirm' && ( |
| 409 | <ValidateSizeLimit |
| 410 | onValidate={sizeLimitCheckQuery} |
| 411 | projectRef={projectRef} |
| 412 | isLoadingBucketEstimate={isBucketEstimatePending} |
| 413 | /> |
| 414 | )} |
| 415 | </FormItemLayout> |
| 416 | )} |
| 417 | /> |
| 418 | {fileSizeLimitError && ( |
| 419 | <FormMessage className="ml-auto mt-2 text-right w-1/2"> |
| 420 | <StorageFileSizeLimitErrorMessage |
| 421 | error={fileSizeLimitError} |
| 422 | projectRef={projectRef} |
| 423 | /> |
| 424 | </FormMessage> |
| 425 | )} |
| 426 | </CardContent> |
| 427 | {hasLimitedStorageAccess && ( |
| 428 | <UpgradeToPro |
| 429 | fullWidth |
| 430 | variant="primary" |
| 431 | source="storageSizeLimit" |
| 432 | featureProposition="configure upload file size limits in Storage" |
| 433 | primaryText="Free Plan has a fixed upload file size limit of 50 MB" |
| 434 | secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes( |
| 435 | STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED |
| 436 | )} and unlock image transformations.`} |
| 437 | /> |
| 438 | )} |
| 439 | {isSpendCapOn && ( |
| 440 | <UpgradeToPro |
| 441 | fullWidth |
| 442 | addon="spendCap" |
| 443 | variant="default" |
| 444 | source="storageSizeLimit" |
| 445 | featureProposition="increase the file upload size limits in Storage" |
| 446 | buttonText="Disable spend cap" |
| 447 | primaryText="Reduced max upload file size limit due to spend cap" |
| 448 | secondaryText={`Disable your spend cap to allow file uploads of up to ${formatBytes( |
| 449 | STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED |
| 450 | )}.`} |
| 451 | /> |
| 452 | )} |
| 453 | |
| 454 | {!canUpdateStorageSettings && ( |
| 455 | <CardContent> |
| 456 | <p className="text-sm text-foreground-light"> |
| 457 | You need additional permissions to update storage settings |
| 458 | </p> |
| 459 | </CardContent> |
| 460 | )} |
| 461 | |
| 462 | <CardFooter className="justify-end space-x-2"> |
| 463 | {form.formState.isDirty && ( |
| 464 | <Button |
| 465 | type="default" |
| 466 | htmlType="reset" |
| 467 | onClick={() => form.reset()} |
| 468 | disabled={ |
| 469 | !form.formState.isDirty || !canUpdateStorageSettings || isUpdating |
| 470 | } |
| 471 | > |
| 472 | Cancel |
| 473 | </Button> |
| 474 | )} |
| 475 | <Button |
| 476 | type={hasLimitedStorageAccess ? 'default' : 'primary'} |
| 477 | htmlType="submit" |
| 478 | loading={isUpdating} |
| 479 | disabled={ |
| 480 | !canUpdateStorageSettings || isUpdating || !form.formState.isDirty |
| 481 | } |
| 482 | > |
| 483 | Save |
| 484 | </Button> |
| 485 | </CardFooter> |
| 486 | </Card> |
| 487 | </form> |
| 488 | </> |
| 489 | )} |
| 490 | </> |
| 491 | )} |
| 492 | </Form> |
| 493 | </PageSectionContent> |
| 494 | </PageSection> |
| 495 | </PageContainer> |
| 496 | ) |
| 497 | } |