StorageSettings.tsx 20 KB

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