StorageSettings.tsx497 lines · main
1import { zodResolver } from '@hookform/resolvers/zod'
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { IS_PLATFORM, useFlag, useParams } from 'common'
4import { useEffect, useMemo, useState } from 'react'
5import { SubmitHandler, useForm } from 'react-hook-form'
6import { toast } from 'sonner'
7import {
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'
24import { Admonition } from 'ui-patterns/admonition'
25import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
26import { PageContainer } from 'ui-patterns/PageContainer'
27import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
28import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
29import * as z from 'zod'
30
31import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage'
32import {
33 StorageListV2MigratingCallout,
34 StorageListV2MigrationCallout,
35} from './StorageListV2MigrationCallout'
36import {
37 STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED,
38 STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED,
39 StorageSizeUnits,
40} from './StorageSettings.constants'
41import {
42 convertFromBytes,
43 convertToBytes,
44 encodeBucketLimitErrorMessage,
45} from './StorageSettings.utils'
46import { ValidateSizeLimit } from './StorageSettings.ValidateSizeLimit'
47import AlertError from '@/components/ui/AlertError'
48import { InlineLink } from '@/components/ui/InlineLink'
49import NoPermission from '@/components/ui/NoPermission'
50import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
51import { useProjectStorageConfigQuery } from '@/data/config/project-storage-config-query'
52import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation'
53import { useLargestBucketSizeLimitsCheck } from '@/data/storage/buckets-max-size-limit-query'
54import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
55import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
56import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
57import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
58import { DOCS_URL } from '@/lib/constants'
59import { formatBytes } from '@/lib/helpers'
60
61const formId = 'storage-settings-form'
62
63interface StorageSettingsState {
64 fileSizeLimit: number
65 unit: StorageSizeUnits
66 imageTransformationEnabled: boolean
67}
68
69export 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}