Supabase will install the{' '}
{wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''}
diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
index 4aa8e54520c..fe4b4a82a18 100644
--- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
+++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
@@ -18,7 +18,7 @@ import { formatWrapperTables } from '../Integrations/Wrappers/Wrappers.utils'
import { SchemaEditor } from '../TableGridEditor/SidePanelEditor/SchemaEditor'
import { getAnalyticsBucketFDWServerName } from './AnalyticsBuckets/AnalyticsBucketDetails/AnalyticsBucketDetails.utils'
import { useAnalyticsBucketAssociatedEntities } from './AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities'
-import { getDecryptedParameters } from './ImportForeignSchemaDialog.utils'
+import { getDecryptedParameters } from './Storage.utils'
export interface ImportForeignSchemaDialogProps {
namespace: string
@@ -111,6 +111,7 @@ export const ImportForeignSchemaDialog = ({
ref: project?.ref,
connectionString: project?.connectionString ?? undefined,
wrapper,
+ wrapperMeta,
})
const formValues: Record = {
diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts
deleted file mode 100644
index 1b1f6d51720..00000000000
--- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.utils.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { type FDW } from 'data/fdw/fdws-query'
-import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
-import { INTEGRATIONS } from '../Integrations/Landing/Integrations.constants'
-import { WrapperMeta } from '../Integrations/Wrappers/Wrappers.types'
-import { convertKVStringArrayToJson } from '../Integrations/Wrappers/Wrappers.utils'
-
-export const getDecryptedParameters = async ({
- ref,
- connectionString,
- wrapper,
-}: {
- ref?: string
- connectionString?: string
- wrapper: FDW
-}) => {
- const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
- const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
- const wrapperServerOptions = wrapperMeta.server.options
-
- const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? [])
-
- const paramsToBeDecrypted = Object.fromEntries(
- new Map(
- Object.entries(serverOptions).filter(([key, value]) => {
- return wrapperServerOptions.find((option) => option.name === key)?.encrypted
- })
- )
- )
-
- const decryptedValues = await getDecryptedValues({
- projectRef: ref,
- connectionString: connectionString,
- ids: Object.values(paramsToBeDecrypted),
- })
-
- const paramsWithDecryptedValues = Object.fromEntries(
- new Map(
- Object.entries(paramsToBeDecrypted).map(([name, id]) => {
- const decryptedValue = decryptedValues[id]
- return [name, decryptedValue]
- })
- )
- )
-
- return {
- ...serverOptions,
- ...paramsWithDecryptedValues,
- }
-}
diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts
index 093bfdfb8af..bb4d1110a84 100644
--- a/apps/studio/components/interfaces/Storage/Storage.utils.ts
+++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts
@@ -1,8 +1,13 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
-import { difference, groupBy } from 'lodash'
+import { difference } from 'lodash'
import { useRouter } from 'next/router'
+import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
+import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
+import { FDW } from 'data/fdw/fdws-query'
import { Bucket } from 'data/storage/buckets-query'
+import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query'
+import { createWrappedSymbol } from 'lib/helpers'
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants'
import type { StoragePolicyFormField } from './Storage.types'
@@ -16,13 +21,18 @@ const shortHash = (str: string) => {
return new Uint32Array([hash])[0].toString(36)
}
+export type PoliciesByBucket = { name: string | Symbol; policies: PostgresPolicy[] }[]
+
/**
* Formats the policies from the objects table in the storage schema
* to be consumable for the storage policies dashboard.
- * Output: [{ bucket: , policies: }]
- * @param {Array} policies: All policies from a table in a schema
+ *
+ * @param policies All policies from a table in a schema
*/
-export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPolicy[]) => {
+export const formatPoliciesForStorage = (
+ buckets: Bucket[],
+ policies: PostgresPolicy[]
+): PoliciesByBucket => {
if (policies.length === 0) return []
/**
@@ -34,15 +44,20 @@ export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPo
*/
const formattedPolicies = formatStoragePolicies(buckets, policies)
- /**
- * Package policies by grouping them by bucket:
- * [{ name: , policies: }]
- */
const policiesByBucket = groupPoliciesByBucket(formattedPolicies)
-
return policiesByBucket
}
+/**
+ * Policy that belongs to a bucket which is not loaded yet (might not have been
+ * paginated to yet, or might have been deleted)
+ */
+export const UNKNOWN_BUCKET_SYMBOL = createWrappedSymbol('unknown-bucket', 'Unknown')
+/**
+ * Policy that is not associated with a specific bucket
+ */
+export const UNGROUPED_POLICY_SYMBOL = createWrappedSymbol('ungrouped-policy', 'Ungrouped')
+
const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) => {
const availableBuckets = buckets.map((bucket) => bucket.name)
const formattedPolicies = policies.map((policy) => {
@@ -53,16 +68,16 @@ const formatStoragePolicies = (buckets: Bucket[], policies: PostgresPolicy[]) =>
? extractBucketNameFromDefinition(policyDefinition)
: extractBucketNameFromDefinition(policyCheck)
- if (bucketName && availableBuckets.includes(bucketName)) {
- // [JOSHEN TODO] We cannot override definition here anymore cause we're gonna be using the auth editor
- // const definition = policyDefinition !== null ? policyDefinition : policyCheck
+ if (bucketName) {
+ const isBucketLoaded = availableBuckets.includes(bucketName)
+
return {
...policy,
- bucket: bucketName,
+ bucket: isBucketLoaded ? bucketName : UNKNOWN_BUCKET_SYMBOL,
}
}
- return { ...policy, bucket: 'Ungrouped' }
+ return { ...policy, bucket: UNGROUPED_POLICY_SYMBOL }
})
return formattedPolicies
@@ -78,11 +93,17 @@ export const extractBucketNameFromDefinition = (definition: string | null) => {
return bucketDefinition ? bucketDefinition.split("'")[1] : null
}
-const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string })[]) => {
- const policiesByBucket = groupBy(policies, 'bucket')
- return Object.keys(policiesByBucket).map((bucketName) => {
- return { name: bucketName, policies: policiesByBucket[bucketName] }
+const groupPoliciesByBucket = (policies: (PostgresPolicy & { bucket: string | Symbol })[]) => {
+ const policiesByBucket = new Map()
+ policies.forEach((policy) => {
+ if (!policiesByBucket.has(policy.bucket)) {
+ policiesByBucket.set(policy.bucket, [])
+ }
+ policiesByBucket.get(policy.bucket)?.push(policy)
})
+ return [
+ ...policiesByBucket.entries().map(([bucketName, policies]) => ({ name: bucketName, policies })),
+ ]
}
export const createPayloadsForAddPolicy = (
@@ -196,3 +217,47 @@ export const useStorageV2Page = () => {
const router = useRouter()
return router.pathname.split('/')[4] as undefined | 'files' | 'analytics' | 'vectors' | 's3'
}
+
+export const getDecryptedParameters = async ({
+ ref,
+ connectionString,
+ wrapper,
+ wrapperMeta,
+}: {
+ ref?: string
+ connectionString?: string
+ wrapper: FDW
+ wrapperMeta: WrapperMeta
+}) => {
+ const wrapperServerOptions = wrapperMeta.server.options
+
+ const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? [])
+
+ const paramsToBeDecrypted = Object.fromEntries(
+ new Map(
+ Object.entries(serverOptions).filter(([key, value]) => {
+ return wrapperServerOptions.find((option) => option.name === key)?.encrypted
+ })
+ )
+ )
+
+ const decryptedValues = await getDecryptedValues({
+ projectRef: ref,
+ connectionString: connectionString,
+ ids: Object.values(paramsToBeDecrypted),
+ })
+
+ const paramsWithDecryptedValues = Object.fromEntries(
+ new Map(
+ Object.entries(paramsToBeDecrypted).map(([name, id]) => {
+ const decryptedValue = decryptedValues[id]
+ return [name, decryptedValue]
+ })
+ )
+ )
+
+ return {
+ ...serverOptions,
+ ...paramsWithDecryptedValues,
+ }
+}
diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx
index 641f0acd8eb..09f21e6e093 100644
--- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx
+++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx
@@ -1,23 +1,20 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { useParams } from 'common'
import { isEmpty } from 'lodash'
-import { Search, X } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import PolicyEditorModal from 'components/interfaces/Auth/Policies/PolicyEditorModal'
-import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation'
import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database-policy-delete-mutation'
import { useDatabasePolicyUpdateMutation } from 'data/database-policies/database-policy-update-mutation'
-import { useBucketsQuery } from 'data/storage/buckets-query'
+import { usePaginatedBucketsQuery } from 'data/storage/buckets-query'
+import { useDebouncedValue } from 'hooks/misc/useDebouncedValue'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
-import { Button } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
-import { Input } from 'ui-patterns/DataInputs/Input'
-import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
@@ -27,10 +24,10 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
-import { formatPoliciesForStorage } from '../Storage.utils'
+import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils'
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
+import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
import StoragePoliciesEditPolicyModal from './StoragePoliciesEditPolicyModal'
-import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
export const StoragePolicies = () => {
const { ref: projectRef } = useParams()
@@ -46,8 +43,19 @@ export const StoragePolicies = () => {
'search',
parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
)
+ const debouncedSearchString = useDebouncedValue(searchString, 250)
- const { data: buckets = [], isPending: isLoadingBuckets } = useBucketsQuery({ projectRef })
+ const {
+ data: bucketsData,
+ isPending: isLoadingBuckets,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = usePaginatedBucketsQuery({
+ projectRef,
+ search: debouncedSearchString || undefined,
+ })
+ const buckets = useMemo(() => bucketsData?.pages.flatMap((page) => page) ?? [], [bucketsData])
const {
data: policies = [],
@@ -65,13 +73,17 @@ export const StoragePolicies = () => {
onError: () => {},
})
const { mutateAsync: updateDatabasePolicy } = useDatabasePolicyUpdateMutation()
- const { mutate: deleteDatabasePolicy } = useDatabasePolicyDeleteMutation({
- onSuccess: async () => {
- await refetch()
- toast.success('Successfully deleted policy!')
- setSelectedPolicyToDelete(undefined)
- },
- })
+ const { mutate: deleteDatabasePolicy, isPending: isDeletingPolicy } =
+ useDatabasePolicyDeleteMutation({
+ onSuccess: async () => {
+ await refetch()
+ toast.success('Successfully deleted policy!')
+ setSelectedPolicyToDelete(undefined)
+ },
+ onError: (error: any) => {
+ toast.error(`Failed to delete policy: ${error.message}`)
+ },
+ })
// Only use storage policy editor when creating new policies for buckets
const showStoragePolicyEditor =
@@ -81,50 +93,46 @@ export const StoragePolicies = () => {
const showGeneralPolicyEditor = !isEmpty(isEditingPolicyForBucket) && !showStoragePolicyEditor
- // Policies under storage.objects
- const storageObjectsPolicies = policies.filter(
- (x) => x.schema === 'storage' && x.table === 'objects'
- )
- const formattedStorageObjectPolicies = formatPoliciesForStorage(buckets, storageObjectsPolicies)
- const ungroupedPolicies =
- formattedStorageObjectPolicies.find((x) => x.name === 'Ungrouped')?.policies ?? []
-
// Policies under storage.buckets
const storageBucketPolicies = policies.filter(
(x) => x.schema === 'storage' && x.table === 'buckets'
)
+ // Policies under storage.objects
+ const storageObjectsPolicies = policies.filter(
+ (x) => x.schema === 'storage' && x.table === 'objects'
+ )
- /**
- * Filter buckets based on search string
- * - Filter buckets by name matching the search string
- * - Show all policies for filtered buckets (policies are not filtered)
- */
- const filteredBucketsWithPolicies = useMemo(() => {
- const searchFilter = searchString?.toLowerCase() || ''
+ const formattedStorageObjectPolicies = useMemo(
+ () => formatPoliciesForStorage(buckets, storageObjectsPolicies),
+ [buckets, storageObjectsPolicies]
+ )
- // Filter buckets by name if search filter is present
- const filteredBucketsList = searchFilter
- ? buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchFilter))
- : buckets
+ const ungroupedPolicies =
+ formattedStorageObjectPolicies.find((x) => x.name === UNGROUPED_POLICY_SYMBOL)?.policies ?? []
+ const bucketsWithPolicies = useMemo(() => {
// Get policies for filtered buckets (show all policies, don't filter them)
- // Show all filtered buckets, even if they don't have policies (similar to auth/policies.tsx)
- const filteredBucketsWithPoliciesList = filteredBucketsList.map((bucket) => {
- const policies =
+ // Show all filtered buckets, even if they don't have policies
+ return buckets.map((bucket) => {
+ const bucketPolicies =
formattedStorageObjectPolicies.find((x) => x.name === bucket.name)?.policies ?? []
- return { bucket, policies }
+ return { bucket, policies: bucketPolicies }
})
+ }, [buckets, formattedStorageObjectPolicies])
- // Schema-level policies should always be shown, unaffected by search filter
- return filteredBucketsWithPoliciesList
- }, [buckets, searchString, formattedStorageObjectPolicies])
-
- const onSelectPolicyAdd = (bucketName = '', table = '') => {
+ const onSelectPolicyAdd: SelectBucketPolicyForAction['addPolicy'] = (
+ bucketName = '',
+ table = ''
+ ) => {
setSelectedPolicyToEdit(undefined)
setIsEditingPolicyForBucket({ bucket: bucketName, table })
}
- const onSelectPolicyEdit = (policy: any, bucketName = '', table = '') => {
+ const onSelectPolicyEdit: SelectBucketPolicyForAction['editPolicy'] = (
+ policy,
+ bucketName = '',
+ table = ''
+ ) => {
setIsEditingPolicyForBucket({ bucket: bucketName, table })
setSelectedPolicyToEdit(policy)
}
@@ -133,7 +141,8 @@ export const StoragePolicies = () => {
setIsEditingPolicyForBucket(undefined)
}
- const onSelectPolicyDelete = (policy: any) => setSelectedPolicyToDelete(policy)
+ const onSelectPolicyDelete: SelectBucketPolicyForAction['deletePolicy'] = (policy: any) =>
+ setSelectedPolicyToDelete(policy)
const onCancelPolicyDelete = () => setSelectedPolicyToDelete(undefined)
const onSavePolicySuccess = async () => {
@@ -237,72 +246,22 @@ export const StoragePolicies = () => {
) : (
-
-
-
- Buckets
-
- Write policies for each bucket to control access to the bucket and its contents
-
-
-
-
- {buckets.length === 0 && }
-
- {buckets.length > 0 && (
-
- {
- const str = e.target.value
- setSearchString(str)
- }}
- icon={}
- actions={
- searchString ? (
- }
- onClick={() => setSearchString('')}
- />
- ) : null
- }
- />
-
- )}
-
- {searchString.length > 0 && filteredBucketsWithPolicies.length === 0 && (
- setSearchString('')}
- />
- )}
-
- {/* Sections for policies grouped by buckets */}
-
- {filteredBucketsWithPolicies.map(({ bucket, policies }) => {
- return (
-
- )
- })}
-
-
-
+
@@ -363,15 +322,16 @@ export const StoragePolicies = () => {
onSaveSuccess={onSavePolicySuccess}
/>
-
>
)
diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx
index 7296b5168a4..81ece470257 100644
--- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx
+++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx
@@ -5,6 +5,7 @@ import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/Po
import { Bucket } from 'data/storage/buckets-query'
import { FilesBucket as FilesBucketIcon } from 'icons'
+import { forwardRef, type CSSProperties } from 'react'
import {
Badge,
Button,
@@ -24,64 +25,72 @@ interface StoragePoliciesBucketRowProps {
label: string
bucket?: Bucket
policies: PostgresPolicy[]
+ style?: CSSProperties
onSelectPolicyAdd: (bucketName: string | undefined, table: string) => void
onSelectPolicyEdit: (policy: PostgresPolicy, bucketName: string, table: string) => void
onSelectPolicyDelete: (policy: PostgresPolicy) => void
}
-export const StoragePoliciesBucketRow = ({
- table = '',
- label = '',
- bucket,
- policies = [],
- onSelectPolicyAdd = noop,
- onSelectPolicyEdit = noop,
- onSelectPolicyDelete = noop,
-}: StoragePoliciesBucketRowProps) => {
- return (
-
-
-
-
-
-
{label}
- {bucket?.public &&
Public}
+export const StoragePoliciesBucketRow = forwardRef
(
+ (
+ {
+ table = '',
+ label = '',
+ bucket,
+ policies = [],
+ style,
+ onSelectPolicyAdd = noop,
+ onSelectPolicyEdit = noop,
+ onSelectPolicyDelete = noop,
+ },
+ ref
+ ) => {
+ return (
+
+
+
+
+
+ {label}
+ {bucket?.public && Public}
+
-
-
-
- {policies.length === 0 ? (
-
- No policies created yet
-
- ) : (
-
-
-
-
- Name
- Command
- Applied to
-
- Actions
-
-
-
-
- {policies.map((policy) => (
- onSelectPolicyEdit(p, bucket?.name ?? '', table)}
- onSelectDeletePolicy={onSelectPolicyDelete}
- />
- ))}
-
-
-
- )}
-
- )
-}
+
+
+ {policies.length === 0 ? (
+
+ No policies created yet
+
+ ) : (
+
+
+
+
+ Name
+ Command
+ Applied to
+
+ Actions
+
+
+
+
+ {policies.map((policy) => (
+ onSelectPolicyEdit(p, bucket?.name ?? '', table)}
+ onSelectDeletePolicy={onSelectPolicyDelete}
+ />
+ ))}
+
+
+
+ )}
+
+ )
+ }
+)
+StoragePoliciesBucketRow.displayName = 'StoragePoliciesBucketRow'
diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx
new file mode 100644
index 00000000000..11b5b443e73
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketsSection.tsx
@@ -0,0 +1,234 @@
+import { PostgresPolicy } from '@supabase/postgres-meta'
+import { useVirtualizer } from '@tanstack/react-virtual'
+import { ChevronUp, Search, X } from 'lucide-react'
+import { forwardRef, useEffect, useState, type HTMLAttributes, type ReactNode } from 'react'
+
+import { useMainScrollContainer } from 'components/layouts/MainScrollContainerContext'
+import { NoSearchResults } from 'components/ui/NoSearchResults'
+import { type Bucket } from 'data/storage/buckets-query'
+import { useStaticEffectEvent } from 'hooks/useStaticEffectEvent'
+import {
+ Button,
+ cn,
+ Collapsible_Shadcn_,
+ CollapsibleContent_Shadcn_,
+ CollapsibleTrigger_Shadcn_,
+} from 'ui'
+import { ShimmeringLoader } from 'ui-patterns'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import {
+ PageSection,
+ PageSectionContent,
+ PageSectionDescription,
+ PageSectionMeta,
+ PageSectionSummary,
+ PageSectionTitle,
+} from 'ui-patterns/PageSection'
+import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
+import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
+
+export type SelectBucketPolicyForAction = {
+ addPolicy: (bucketName?: string, table?: string) => void
+ editPolicy: (policy: PostgresPolicy, bucketName?: string, table?: string) => void
+ deletePolicy: (policy: PostgresPolicy) => void
+}
+
+type BucketsPoliciesProps = {
+ buckets: { bucket: Bucket; policies: PostgresPolicy[] }[]
+ search?: string
+ debouncedSearch?: string
+ setSearch: (search: string) => void
+ actions: SelectBucketPolicyForAction
+ pagination: {
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ fetchNextPage: () => void
+ }
+}
+
+export const BucketsPolicies = ({
+ buckets,
+ search,
+ debouncedSearch,
+ setSearch,
+ actions,
+ pagination,
+}: BucketsPoliciesProps): ReactNode => {
+ const [expanded, setExpanded] = useState(true)
+
+ const showEmptyState = buckets.length === 0 && (!debouncedSearch || debouncedSearch.length === 0)
+
+ return (
+
+
+
+
+ Buckets
+
+ Write policies for each bucket to control access to the bucket and its contents
+
+
+
+
+
+
+
+
+ {showEmptyState && }
+
+ {buckets.length > 0 && (
+
+ {
+ const str = e.target.value
+ setSearch(str)
+ }}
+ icon={}
+ actions={
+ search ? (
+ }
+ onClick={() => setSearch('')}
+ />
+ ) : null
+ }
+ />
+
+ )}
+
+ {!!search && search.length > 0 && buckets.length === 0 && (
+ setSearch('')} />
+ )}
+
+
+
+
+
+
+ )
+}
+
+type BucketsPoliciesVirtualizedListProps = {
+ items: { bucket: Bucket; policies: PostgresPolicy[] }[]
+ actions: SelectBucketPolicyForAction
+ pagination: BucketsPoliciesProps['pagination']
+}
+
+const BucketsPoliciesVirtualizedList = ({
+ items,
+ actions,
+ pagination,
+}: BucketsPoliciesVirtualizedListProps) => {
+ const { hasNextPage, isFetchingNextPage, fetchNextPage } = pagination
+
+ const itemCount = hasNextPage ? items.length + 1 : items.length
+
+ const scrollElement = useMainScrollContainer()
+ const virtualizer = useVirtualizer({
+ count: itemCount,
+ estimateSize: () => 129,
+ overscan: 5,
+ getItemKey: (index) => items[index]?.bucket.name ?? `bucket-${index}`,
+ getScrollElement: () => scrollElement,
+ })
+
+ const virtualItems = virtualizer.getVirtualItems()
+ const lastItem = virtualItems[virtualItems.length - 1]
+
+ const fetchNext = useStaticEffectEvent(() => {
+ if (lastItem && lastItem.index >= items.length - 1 && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ })
+ useEffect(fetchNext, [lastItem, fetchNext])
+
+ return (
+
+ {virtualItems.map((virtualRow) => {
+ const isLoaderRow = virtualRow.index > items.length - 1
+ const commonStyle = {
+ position: 'absolute' as const,
+ top: 0,
+ left: 0,
+ width: '100%',
+ transform: `translateY(${virtualRow.start}px)`,
+ }
+
+ if (isLoaderRow) {
+ return (
+
+ )
+ }
+
+ const item = items[virtualRow.index]
+ if (!item) return null
+
+ return (
+
+
+
+ )
+ })}
+
+ )
+}
+
+type BucketsPoliciesLoaderProps = HTMLAttributes
+
+const BucketsPoliciesLoader = forwardRef(
+ (props: BucketsPoliciesLoaderProps, ref) => (
+
+ )
+)
+BucketsPoliciesLoader.displayName = 'BucketsPoliciesLoader'
diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
index 0630947b9a7..4878a5878bd 100644
--- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
@@ -29,6 +29,7 @@ import {
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
+ Separator,
Switch,
Table,
TableBody,
@@ -156,13 +157,13 @@ export const S3Connection = () => {
) : isProjectActive ? (
-
+
(
{
/>
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
{!isLoadingPermissions && !canUpdateStorageSettings && (
diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageFileSizeLimitErrorMessage.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageFileSizeLimitErrorMessage.tsx
new file mode 100644
index 00000000000..9a3f9e8c315
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageFileSizeLimitErrorMessage.tsx
@@ -0,0 +1,108 @@
+import Link from 'next/link'
+import { type FieldError } from 'react-hook-form'
+
+import { InlineLink } from 'components/ui/InlineLink'
+import { LARGEST_SIZE_LIMIT_BUCKETS_COUNT } from 'data/sql/queries/get-largest-size-limit-buckets'
+import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import {
+ decodeBucketLimitErrorMessage,
+ formatBytesForDisplay,
+ isBucketLimitErrorMessage,
+} from './StorageSettings.utils'
+
+interface StorageFileSizeLimitErrorMessageProps {
+ error?: FieldError
+ projectRef?: string
+}
+
+export const StorageFileSizeLimitErrorMessage = ({
+ error,
+ projectRef,
+}: StorageFileSizeLimitErrorMessageProps) => {
+ if (!error?.message) return null
+
+ const isBucketLimitError = isBucketLimitErrorMessage(error.message)
+ if (!isBucketLimitError) return <>{error.message}>
+
+ const bucketLimitErrorBuckets = decodeBucketLimitErrorMessage(error.message)
+ if (bucketLimitErrorBuckets.length === 0) return null
+
+ const hasUnfetchedBuckets = bucketLimitErrorBuckets.length > LARGEST_SIZE_LIMIT_BUCKETS_COUNT
+
+ const [primaryBucket, ...remainingBuckets] = bucketLimitErrorBuckets
+ const otherBuckets = remainingBuckets.slice(0, LARGEST_SIZE_LIMIT_BUCKETS_COUNT - 1)
+
+ const primaryBucketLimit = formatBytesForDisplay(primaryBucket.limit)
+
+ const primaryBucketLink =
+ projectRef !== undefined ? (
+
+ {primaryBucket.name}
+
+ ) : (
+ {primaryBucket.name}
+ )
+
+ const showOtherCount = otherBuckets.length > 0 && !hasUnfetchedBuckets
+
+ return (
+ <>
+ Global limit must be greater than that of individual buckets.
+
+ Remove or decrease the limit on {primaryBucketLink} ({primaryBucketLimit})
+ {otherBuckets.length > 0 && (
+ <>
+ {' '}
+ and{' '}
+
+
+
+ {showOtherCount && `+${otherBuckets.length} `}other bucket
+ {otherBuckets.length > 1 ? 's' : ''}
+
+
+
+
+ {otherBuckets.map(({ name, limit }) => {
+ const formattedLimit = formatBytesForDisplay(limit)
+ return (
+ -
+ {projectRef !== undefined ? (
+
+ {name} ({formattedLimit})
+
+ ) : (
+
+ {name} ({formattedLimit})
+
+ )}
+
+ )
+ })}
+
+ {hasUnfetchedBuckets && ...and others
}
+
+ {' '}
+ first
+ >
+ )}
+ .
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.ValidateSizeLimit.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.ValidateSizeLimit.tsx
new file mode 100644
index 00000000000..880144e1268
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.ValidateSizeLimit.tsx
@@ -0,0 +1,179 @@
+import { useEffect, useReducer, useState } from 'react'
+import { useWatch, type FieldError } from 'react-hook-form'
+
+import {
+ THRESHOLD_FOR_AUTO_QUERYING_BUCKET_LIMITS,
+ type BucketWithSizeLimit,
+} from 'data/storage/buckets-max-size-limit-query'
+import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
+import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
+import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage'
+import { StorageSizeUnits } from './StorageSettings.constants'
+import { convertToBytes, encodeBucketLimitErrorMessage } from './StorageSettings.utils'
+
+type ValidationState =
+ | { status: 'initial' }
+ | { status: 'validating' }
+ | { status: 'valid' }
+ | { status: 'invalid'; failingBuckets: BucketWithSizeLimit[] }
+ | { status: 'error'; message: string }
+
+type ValidationAction =
+ | { type: 'start' }
+ | { type: 'success'; failingBuckets: BucketWithSizeLimit[] }
+ | { type: 'error'; message: string }
+ | { type: 'reset' }
+
+const validationStateReducer = (
+ state: ValidationState,
+ action: ValidationAction
+): ValidationState => {
+ switch (true) {
+ case (state.status === 'initial' || state.status === 'error') && action.type === 'start':
+ return { status: 'validating' }
+ case state.status === 'validating' && action.type === 'success':
+ return action.failingBuckets.length > 0
+ ? { status: 'invalid', failingBuckets: action.failingBuckets }
+ : { status: 'valid' }
+ case state.status === 'validating' && action.type === 'error':
+ return { status: 'error', message: action.message }
+ case action.type === 'reset':
+ return { status: 'initial' }
+ default:
+ console.warn(
+ `[StorageSettings.ValidateSizeLimit] Invalid transition in validationStateReducer: ${state.status} -> ${action.type}`
+ )
+ return state
+ }
+}
+
+type ValidateSizeLimitProps = {
+ onValidate: () => Promise
+ projectRef?: string
+ isLoadingBucketEstimate?: boolean
+}
+
+export const ValidateSizeLimit = ({
+ onValidate,
+ projectRef,
+ isLoadingBucketEstimate = false,
+}: ValidateSizeLimitProps) => {
+ const fileSizeLimit: number = useWatch({ name: 'fileSizeLimit' })
+ const unit: StorageSizeUnits = useWatch({ name: 'unit' })
+
+ const [showModal, setShowModal] = useState(false)
+ const [validationState, dispatch] = useReducer(validationStateReducer, { status: 'initial' })
+
+ useEffect(() => {
+ dispatch({ type: 'reset' })
+ }, [fileSizeLimit, unit])
+
+ const handleValidate = async () => {
+ dispatch({ type: 'start' })
+ try {
+ const globalLimitInBytes = convertToBytes(fileSizeLimit, unit)
+ const buckets = await onValidate()
+ const failing = buckets.filter((bucket) => bucket.file_size_limit > globalLimitInBytes)
+
+ dispatch({ type: 'success', failingBuckets: failing })
+ } catch (error) {
+ console.error('Error validating bucket size limits: %O', error)
+ const message =
+ error instanceof Error && error.message.length > 0
+ ? error.message
+ : 'Unexpected error while validating bucket limits'
+
+ dispatch({ type: 'error', message })
+ } finally {
+ setShowModal(false)
+ }
+ }
+
+ const canTriggerRevalidation =
+ validationState.status === 'initial' || validationState.status === 'error'
+ const isValidating = validationState.status === 'validating'
+ const showValidationButton = canTriggerRevalidation || isValidating
+
+ const hasUnsuccessfulValidation = validationState.status === 'invalid'
+ const bucketLimitError: FieldError | undefined =
+ validationState.status === 'invalid'
+ ? {
+ type: 'manual',
+ message: encodeBucketLimitErrorMessage(
+ validationState.failingBuckets.map((bucket) => ({
+ name: bucket.name,
+ limit: bucket.file_size_limit,
+ }))
+ ),
+ }
+ : undefined
+ const hasSuccessfulValidation = validationState.status === 'valid'
+
+ const validationError = validationState.status === 'error' ? validationState.message : undefined
+
+ if (isLoadingBucketEstimate) {
+ // Avoid flashing manual validation UI before the estimate query resolves
+ return null
+ }
+
+ return (
+ <>
+ {showValidationButton && (
+
+
+
+
+
+ Check that all existing buckets fit within this limit
+
+
+ )}
+
+ {hasUnsuccessfulValidation && (
+
+
+
+ )}
+
+ {hasSuccessfulValidation && (
+
+ All buckets fit within this limit.
+
+ )}
+
+ {validationError && (
+
+ Failed to validate bucket limits: {validationError}
+
+ )}
+
+ setShowModal(false)}
+ onConfirm={handleValidate}
+ >
+
+ Running this queries the storage.buckets table,
+ which may cause load on your database because you have more than{' '}
+ {THRESHOLD_FOR_AUTO_QUERYING_BUCKET_LIMITS.toLocaleString()} buckets.
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx
index 81b293ff703..bd53ea5f3b7 100644
--- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -14,10 +13,11 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
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 { useBucketsQuery } from 'data/storage/buckets-query'
+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'
import {
@@ -36,13 +36,11 @@ import {
SelectValue_Shadcn_,
Select_Shadcn_,
Switch,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
+import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage'
import {
StorageListV2MigratingCallout,
StorageListV2MigrationCallout,
@@ -52,7 +50,12 @@ import {
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED,
StorageSizeUnits,
} from './StorageSettings.constants'
-import { convertFromBytes, convertToBytes } from './StorageSettings.utils'
+import {
+ convertFromBytes,
+ convertToBytes,
+ encodeBucketLimitErrorMessage,
+} from './StorageSettings.utils'
+import { ValidateSizeLimit } from './StorageSettings.ValidateSizeLimit'
const formId = 'storage-settings-form'
@@ -64,6 +67,8 @@ interface StorageSettingsState {
export const StorageSettings = () => {
const { ref: projectRef } = useParams()
+ const { data: project } = useSelectedProjectQuery()
+
const showMigrationCallout = useFlag('storageMigrationCallout')
const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
@@ -87,6 +92,16 @@ export const StorageSettings = () => {
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 } =
useCheckEntitlements('storage.max_file_size')
@@ -94,17 +109,7 @@ export const StorageSettings = () => {
const isSpendCapOn =
organization?.plan.id === 'pro' && organization?.usage_billing_enabled === false
- const { data: buckets = [], isPending: isLoadingBuckets } = useBucketsQuery({ projectRef })
-
- // Calculate the minimum file size limit from existing buckets
- const minBucketFileSizeLimit = useMemo(() => {
- const bucketLimits = buckets
- .filter((bucket: any) => bucket.file_size_limit && bucket.file_size_limit > 0)
- .map((bucket: any) => bucket.file_size_limit!)
-
- return bucketLimits.length > 0 ? Math.min(...bucketLimits) : 0
- }, [buckets])
-
+ const [isUpdating, setIsUpdating] = useState(false)
const [initialValues, setInitialValues] = useState({
fileSizeLimit: 0,
unit: StorageSizeUnits.MB,
@@ -136,57 +141,71 @@ export const StorageSettings = () => {
path: ['fileSizeLimit'],
})
}
-
- // Validate that global limit is not smaller than any bucket's limit
- if (minBucketFileSizeLimit > 0 && !isLoadingBuckets && buckets.length > 0) {
- const { value: formattedMinBucketLimit } = convertFromBytes(minBucketFileSizeLimit, unit)
-
- if (fileSizeLimit < formattedMinBucketLimit) {
- // Get buckets that would be affected by this too-small global limit
- const affectedBuckets = buckets
- .filter((bucket) => bucket.file_size_limit && bucket.file_size_limit > 0)
- .filter(
- (bucket) => convertFromBytes(bucket.file_size_limit!, unit).value > fileSizeLimit
- )
- .sort((a, b) => (b.file_size_limit ?? 0) - (a.file_size_limit ?? 0))
-
- if (affectedBuckets.length > 0) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: `bucketLimit:${affectedBuckets.map((x) => x.name).join(',')}`,
- path: ['fileSizeLimit'],
- })
- }
- }
- }
})
const form = useForm>({
resolver: zodResolver(FormSchema),
defaultValues: initialValues,
+ mode: 'onSubmit',
+ reValidateMode: 'onSubmit',
})
const { unit: storageUnit } = form.watch()
- const { fileSizeLimit: fileSizeLimitError } = form.formState.errors
- const isBucketLimitError = !!fileSizeLimitError?.message?.startsWith('bucketLimit')
- const affectedBuckets = isBucketLimitError
- ? (fileSizeLimitError?.message ?? '').split(':')[1].split(',')
- : []
- const firstAffectBucketLimit = convertFromBytes(
- buckets.find((x) => x.name === affectedBuckets[0])?.file_size_limit ?? 0
- )
+ const fileSizeLimitError = form.formState.errors.fileSizeLimit
- const { mutate: updateStorageConfig, isPending: isUpdating } =
- useProjectStorageConfigUpdateUpdateMutation({
- onSuccess: () => {
- toast.success('Successfully updated storage settings')
- },
- })
+ 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> = 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),
@@ -216,6 +235,7 @@ export const StorageSettings = () => {
imageTransformationEnabled,
})
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, config])
return (
@@ -244,9 +264,9 @@ export const StorageSettings = () => {
{isListV2Upgrading && }
>
)}
-
@@ -105,9 +118,10 @@ interface VectorBucketIndexExamplesProps {
dimension: number
metadataKeys: string[]
language: 'javascript' | 'sql'
- onLanguageChange: (value: 'javascript' | 'sql') => void
showLanguage: boolean
+ onLanguageChange: (value: 'javascript' | 'sql') => void
onShowLanguageChange: (show: boolean) => void
+ onSelectQueryFromPostgres: () => void
}
function VectorBucketIndexExamples({
@@ -116,20 +130,22 @@ function VectorBucketIndexExamples({
dimension,
metadataKeys,
language,
- onLanguageChange,
showLanguage,
+ onLanguageChange,
onShowLanguageChange,
+ onSelectQueryFromPostgres,
}: VectorBucketIndexExamplesProps) {
- const { ref: projectRef } = useParams()
+ const { ref: projectRef, bucketId } = useParams()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(
PermissionAction.READ,
'service_api_keys'
)
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
- const { serviceKey, secretKey } = canReadAPIKeys
- ? getKeys(apiKeys)
- : { serviceKey: null, secretKey: null }
+ const { secretKey } = canReadAPIKeys ? getKeys(apiKeys) : { secretKey: null }
+
+ const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId })
+ const foreignTable = wrapperInstance?.tables?.find((x) => x.name === indexName)
const dimensionLabel = `Data should match ${dimension} dimension${dimension > 1 ? 's' : ''}`
const startValue = 0.1
@@ -139,7 +155,7 @@ function VectorBucketIndexExamples({
const sqlCode = `-- Insert multiple vectors
insert into
- "${getVectorBucketFDWSchemaName(bucketName)}"."${indexName}" (key, data, metadata)
+ "${foreignTable?.schema}"."${foreignTable?.name}" (key, data, metadata)
values
(
'doc-1',
@@ -228,19 +244,9 @@ const result = await index.putVectors({
- }
- asChild
- >
-
- Docs
-
-
+
{language === 'javascript' ? (
+ ) : !foreignTable ? (
+
+ Query from Postgres
+
+ }
+ />
) : (
<>
-
+
{state === 'not-installed' && (
@@ -210,6 +215,10 @@ export const VectorBucketDetails = () => {
const id = `index-${idx}`
const name = index.indexName
+ const foreignTable = wrapperInstance?.tables?.find(
+ (x) => x.name === index.indexName
+ )
+
return (
{name}
@@ -232,15 +241,14 @@ export const VectorBucketDetails = () => {
/>
- {wrapperInstance ? (
+ {!!foreignTable ? (
<>
- {/* TODO: Proper URL for sql editor */}
e.stopPropagation()}
>
{
className="flex items-center space-x-2"
asChild
>
- {/* TODO: Proper URL for table editor */}
e.stopPropagation()}
>
{
View in Table Editor
+
>
) : null}
{
return `${snakeCase(bucketId)}_keys`
}
-export const getVectorBucketFDWSchemaName = (bucketId: string) => {
- return `fdw_vector_${snakeCase(bucketId)}`
+export const getVectorBucketFDWServerName = (bucketId: string) => {
+ return `${getVectorBucketFDWName(bucketId)}_server`
}
export const getVectorBucketFDWName = (bucketId: string) => {
diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
index 83781c793ac..cab44ab4f0f 100644
--- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx
@@ -43,7 +43,6 @@ import {
TooltipTrigger,
cn,
} from 'ui'
-import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { RoleImpersonationPopover } from '../RoleImpersonationSelector/RoleImpersonationPopover'
import ViewEntityAutofixSecurityModal from './ViewEntityAutofixSecurityModal'
@@ -78,7 +77,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
const { isSchemaLocked } = useIsProtectedSchema({ schema: table.schema })
- const { mutate: updateTable } = useTableUpdateMutation({
+ const { mutate: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({
onError: (error) => {
toast.error(`Failed to toggle RLS: ${error.message}`)
},
@@ -592,15 +591,18 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
/>
{isTable && (
-
)}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
index 12d2f525bc2..80a1ba852d5 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx
@@ -5,6 +5,9 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
+import { useDatabasePolicyCreateMutation } from 'data/database-policies/database-policy-create-mutation'
+import { databasePoliciesKeys } from 'data/database-policies/keys'
import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
@@ -24,6 +27,7 @@ import { getTables } from 'data/tables/tables-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useConfirmOnClose, type ConfirmOnCloseModalProps } from 'hooks/ui/useConfirmOnClose'
+import { usePHFlag } from 'hooks/ui/useFlag'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useTrack } from 'lib/telemetry/track'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
@@ -64,6 +68,7 @@ type SaveTableParamsBase = {
columns: ColumnField[]
foreignKeyRelations: ForeignKey[]
resolve: () => void
+ generatedPolicies?: GeneratedPolicy[]
}
type SaveTableParamsNew = SaveTableParamsBase & {
@@ -130,13 +135,20 @@ export const SidePanelEditor = ({
const tabsSnap = useTabsStateSnapshot()
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
+ const track = useTrack()
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
- const track = useTrack()
+ const getImpersonatedRoleState = useGetImpersonatedRoleState()
+ const generatePoliciesFlag = usePHFlag('tableCreateGeneratePolicies')
const [isEdited, setIsEdited] = useState(false)
+ const { data: publications } = useDatabasePublicationsQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({
checkIsDirty: () => isEdited,
onClose: () => {
@@ -161,16 +173,13 @@ export const SidePanelEditor = ({
toast.success('Successfully updated row')
},
})
- const { data: publications } = useDatabasePublicationsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
const { mutateAsync: createPublication } = useDatabasePublicationCreateMutation()
const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({
onError: () => {},
})
-
- const getImpersonatedRoleState = useGetImpersonatedRoleState()
+ const { mutateAsync: createPolicy } = useDatabasePolicyCreateMutation({
+ onError: () => {}, // Errors handled inline
+ })
const isDuplicating = snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate'
@@ -472,13 +481,18 @@ export const SidePanelEditor = ({
}
}
- const saveTable = async (params: SaveTableParams) => {
- // action and payload are not destructured here to preserve type
- // narrowing later on
- const { configuration, columns, foreignKeyRelations, resolve } = params
-
+ const saveTable = async ({
+ action,
+ payload,
+ configuration,
+ columns,
+ foreignKeyRelations,
+ generatedPolicies = [],
+ resolve,
+ }: SaveTableParams) => {
let toastId
let saveTableError = false
+
const {
importContent,
isRLSEnabled,
@@ -489,46 +503,75 @@ export const SidePanelEditor = ({
} = configuration
try {
- if (params.action === 'create') {
- toastId = toast.loading(`Creating new table: ${params.payload.name}...`)
+ if (action === 'create') {
+ toastId = toast.loading(`Creating new table: ${payload.name}...`)
- const table = await createTable({
+ const { table, failedPolicies } = await createTable({
projectRef: project?.ref!,
connectionString: project?.connectionString,
toastId,
- payload: params.payload,
+ payload,
columns,
foreignKeyRelations,
isRLSEnabled,
importContent,
organizationSlug: org?.slug,
+ generatedPolicies,
+ onCreatePoliciesSuccess: () => track('rls_generated_policies_created'),
})
+
if (isRealtimeEnabled) await updateTableRealtime(table, true)
+ // Invalidate queries for table creation
await Promise.all([
queryClient.invalidateQueries({
queryKey: tableKeys.list(project?.ref, table.schema, includeColumns),
}),
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
+ queryClient.invalidateQueries({ queryKey: databasePoliciesKeys.list(project?.ref) }),
])
- toast.success(`Table ${table.name} is good to go!`, { id: toastId })
+ // Show success toast after everything is complete
+ if (failedPolicies.length > 0) {
+ toast.success(
+ `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`,
+ {
+ id: toastId,
+ description: (
+
+ {failedPolicies.map((x) => (
+ - {x.name}
+ ))}
+
+ ),
+ }
+ )
+ } else {
+ toast.success(`Table ${table.name} is good to go!`, { id: toastId })
+ }
+
+ // Track experiment conversion if user is in the experiment
+ if (generatePoliciesFlag !== undefined) {
+ track('table_create_generate_policies_experiment_converted', {
+ experiment_id: 'tableCreateGeneratePolicies',
+ variant: generatePoliciesFlag ? 'treatment' : 'control',
+ has_rls_enabled: isRLSEnabled,
+ has_rls_policies: generatedPolicies.length > 0,
+ has_generated_policies: generatedPolicies.length > 0,
+ })
+ }
+
onTableCreated(table)
- } else if (params.action === 'duplicate' && !!selectedTable) {
+ } else if (action === 'duplicate' && !!selectedTable) {
const tableToDuplicate = selectedTable
toastId = toast.loading(`Duplicating table: ${tableToDuplicate.name}...`)
- const table = await duplicateTable(
- project?.ref!,
- project?.connectionString,
- params.payload,
- {
- isRLSEnabled,
- isDuplicateRows,
- duplicateTable: tableToDuplicate,
- foreignKeyRelations,
- }
- )
+ const table = await duplicateTable(project?.ref!, project?.connectionString, payload, {
+ isRLSEnabled,
+ isDuplicateRows,
+ duplicateTable: tableToDuplicate,
+ foreignKeyRelations,
+ })
if (isRealtimeEnabled) await updateTableRealtime(table, isRealtimeEnabled)
await Promise.all([
@@ -543,7 +586,7 @@ export const SidePanelEditor = ({
{ id: toastId }
)
onTableCreated(table)
- } else if (params.action === 'update' && selectedTable) {
+ } else if (action === 'update' && selectedTable) {
toastId = toast.loading(`Updating table: ${selectedTable.name}...`)
const { table, hasError } = await updateTable({
@@ -551,7 +594,7 @@ export const SidePanelEditor = ({
connectionString: project?.connectionString,
toastId,
table: selectedTable,
- payload: params.payload,
+ payload,
columns,
foreignKeyRelations,
existingForeignKeyRelations,
@@ -571,10 +614,10 @@ export const SidePanelEditor = ({
`Table ${table.name} has been updated but there were some errors. Please check these errors separately.`
)
} else {
- if (ref && params.payload.name) {
+ if (ref && payload.name) {
// [Joshen] Only table entities can be updated via the dashboard
const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id })
- tabsSnap.updateTab(tabId, { label: params.payload.name })
+ tabsSnap.updateTab(tabId, { label: payload.name })
}
toast.success(`Successfully updated ${table.name}!`, { id: toastId })
}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts
index 9041b931c92..1c09396321b 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts
@@ -141,6 +141,7 @@ describe('createTable', () => {
event: {
action: 'table_created',
properties: {
+ has_generated_policies: false,
method: 'table_editor',
schema_name: 'public',
table_name: 'test_table',
@@ -160,7 +161,10 @@ describe('createTable', () => {
})
)
- expect(result).toStrictEqual(mockTableResult)
+ expect(result).toStrictEqual({
+ failedPolicies: [],
+ table: mockTableResult,
+ })
})
it('should create a table with RLS enabled', async () => {
@@ -355,7 +359,10 @@ describe('createTable', () => {
isRLSEnabled: false,
})
- expect(result).toStrictEqual(mockTableResult)
+ expect(result).toStrictEqual({
+ failedPolicies: [],
+ table: mockTableResult,
+ })
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to track table creation event:',
expect.any(Error)
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
index 675c64b1223..54a4831deaf 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx
@@ -5,10 +5,12 @@ import Papa from 'papaparse'
import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
+import { GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import SparkBar from 'components/ui/SparkBar'
import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation'
import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation'
import { updateDatabaseColumn } from 'data/database-columns/database-column-update-mutation'
+import { createDatabasePolicy } from 'data/database-policies/database-policy-create-mutation'
import type { Constraint } from 'data/database/constraints-query'
import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants'
import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
@@ -496,6 +498,8 @@ export const createTable = async ({
isRLSEnabled,
importContent,
organizationSlug,
+ generatedPolicies = [],
+ onCreatePoliciesSuccess,
}: {
projectRef: string
connectionString?: string | null
@@ -510,6 +514,8 @@ export const createTable = async ({
isRLSEnabled: boolean
importContent?: ImportContent
organizationSlug?: string
+ generatedPolicies?: GeneratedPolicy[]
+ onCreatePoliciesSuccess?: () => void
}) => {
const queryClient = getQueryClient()
@@ -583,6 +589,39 @@ export const createTable = async ({
queryKey: ['table', 'create-with-columns'],
})
+ // 6. Create generated RLS policies if any
+ // [Joshen] Possible area for optimization to create all policies in a single query call
+ // Can be subsequently added to the table creation SQL as well for a single transaction
+
+ const failedPolicies: GeneratedPolicy[] = []
+ if (generatedPolicies.length > 0 && isRLSEnabled) {
+ toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
+ await Promise.all(
+ generatedPolicies.map(async (policy) => {
+ try {
+ return await createDatabasePolicy({
+ projectRef,
+ connectionString,
+ payload: {
+ name: policy.name,
+ table: policy.table,
+ schema: policy.schema,
+ definition: policy.definition,
+ check: policy.check,
+ action: policy.action,
+ command: policy.command,
+ roles: policy.roles,
+ },
+ })
+ } catch (error: any) {
+ console.error('Failed to generate policy', error.message)
+ failedPolicies.push(policy)
+ }
+ })
+ )
+ onCreatePoliciesSuccess?.()
+ }
+
// Track table creation event (fire-and-forget to avoid blocking)
sendEvent({
event: {
@@ -591,6 +630,7 @@ export const createTable = async ({
method: 'table_editor',
schema_name: payload.schema,
table_name: payload.name,
+ has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled,
},
groups: {
project: projectRef,
@@ -681,7 +721,6 @@ export const createTable = async ({
type="horizontal"
barClass="bg-brand"
labelBottom={`Adding ${importContent.rows.length.toLocaleString()} rows to ${table.name}`}
- labelBottomClass=""
labelTop={`${progress.toFixed(2)}%`}
labelTopClass="tabular-nums"
/>
@@ -721,7 +760,7 @@ export const createTable = async ({
})
// Finally, return the created table
- return table
+ return { table, failedPolicies }
}
/** TODO: Refactor to do in a single transaction */
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx
index b9f18ae1d18..abc9c488d59 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSDisableModal.tsx
@@ -5,7 +5,7 @@ import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { DOCS_URL } from 'lib/constants'
import { Alert } from 'ui'
-export default function RLSDisableModalContent() {
+export function RLSDisableModalContent() {
const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path'])
return (
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx
new file mode 100644
index 00000000000..f40562843e8
--- /dev/null
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyList.tsx
@@ -0,0 +1,105 @@
+import { X } from 'lucide-react'
+
+import {
+ Badge,
+ Card,
+ cn,
+ HoverCard_Shadcn_,
+ HoverCardContent_Shadcn_,
+ HoverCardTrigger_Shadcn_,
+ SimpleCodeBlock,
+} from 'ui'
+
+export interface PolicyListItemData {
+ name: string
+ command?: string | null
+ sql?: string | null
+ isNew?: boolean
+}
+
+interface PolicyListItemProps {
+ policy: PolicyListItemData
+ onRemove?: () => void
+}
+
+/**
+ * A shared component for displaying policy items with hover preview
+ * Used in RLSManagement and policy creation toast notifications
+ */
+export const PolicyListItem = ({ policy, onRemove }: PolicyListItemProps) => {
+ return (
+
+
+
+
{policy.name}
+
+
+ {policy.command && {policy.command}}
+ {policy.isNew && New}
+ {!!onRemove && policy.isNew && (
+
+ )}
+
+
+
+
+ {policy.sql ? (
+
+ {policy.sql}
+
+ ) : (
+ No definition available.
+ )}
+
+
+ )
+}
+
+interface PolicyListProps {
+ disabled?: boolean
+ policies: PolicyListItemData[]
+ className?: string
+ onRemove?: (index: number) => void
+}
+
+/**
+ * A list of policies with hover previews
+ * Used in RLSManagement and toast notifications
+ */
+export const PolicyList = ({
+ disabled = false,
+ policies,
+ className,
+ onRemove,
+}: PolicyListProps) => {
+ return (
+
+
+ {policies.map((policy, idx) => (
+
onRemove(idx) : undefined}
+ />
+ ))}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx
new file mode 100644
index 00000000000..4477cc88d23
--- /dev/null
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/PolicyListEmptyState.tsx
@@ -0,0 +1,242 @@
+import { useMemo, useState } from 'react'
+import { toast } from 'sonner'
+
+import {
+ type GeneratedPolicy,
+ generateStartingPoliciesForTable,
+} from 'components/interfaces/Auth/Policies/Policies.utils'
+import { AIOptInModal } from 'components/ui/AIAssistantPanel/AIOptInModal'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import {
+ ForeignKeyConstraint,
+ useForeignKeyConstraintsQuery,
+} from 'data/database/foreign-key-constraints-query'
+import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { useTrack } from 'lib/telemetry/track'
+import { Button, Card, CardContent, cn } from 'ui'
+import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types'
+import { ColumnField } from '../../SidePanelEditor.types'
+
+interface PolicyListEmptyStateProps {
+ schema: string
+ tableName?: string
+ columns: ColumnField[]
+ foreignKeyRelations: ForeignKey[]
+ isNewRecord: boolean
+ isRLSEnabled: boolean
+ isDuplicating: boolean
+ onGeneratedPoliciesChange?: (policies: GeneratedPolicy[]) => void
+}
+
+export const PolicyListEmptyState = ({
+ schema,
+ tableName,
+ columns,
+ foreignKeyRelations,
+ isNewRecord,
+ isRLSEnabled,
+ isDuplicating,
+ onGeneratedPoliciesChange,
+}: PolicyListEmptyStateProps) => {
+ const track = useTrack()
+ const { data: project } = useSelectedProjectQuery()
+ const { includeSchemaMetadata } = useOrgAiOptInLevel()
+
+ const [isGenerating, setIsGenerating] = useState(false)
+ const [generateFailed, setGenerateFailed] = useState(false)
+ const [isOptInModalOpen, setIsOptInModalOpen] = useState(false)
+
+ // Fetch foreign key constraints for policy generation BFS traversal
+ const { data: schemaForeignKeys } = useForeignKeyConstraintsQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ schema: schema,
+ },
+ {
+ enabled: isNewRecord && !isDuplicating && !!schema,
+ }
+ )
+
+ const emptyStateMessage = useMemo(() => {
+ if (!generateFailed) {
+ return {
+ title: 'Generate starting policies',
+ description: (
+ <>
+ Starter policies are generated from your table relationships to{' '}
+ auth.users or, if none exist, using AI.
+ >
+ ),
+ }
+ }
+
+ if (!includeSchemaMetadata) {
+ return {
+ title: 'Unable to generate policies',
+ description: (
+ <>
+ We couldn't detect any relationships to auth.users to suggest policies.
+ Enable schema metadata sharing to use our AI-assisted policy generator.
+ >
+ ),
+ }
+ }
+
+ return {
+ title: 'We could not generate policies for this table',
+ description:
+ "Automatic policy generation wasn't possible for this table. Update the schema and try again, or add policies manually after creating the table.",
+ }
+ }, [generateFailed, includeSchemaMetadata])
+
+ const { title, description } = emptyStateMessage
+ const showPermissionButton = generateFailed && !includeSchemaMetadata
+ const allowPolicyGeneration = isNewRecord && !isDuplicating
+
+ const convertForeignKeysToConstraints = (fks: ForeignKey[]): ForeignKeyConstraint[] => {
+ if (!tableName || !schema) {
+ return []
+ }
+
+ return fks
+ .filter((fk) => fk.columns && fk.columns.length > 0) // Only include FKs with columns
+ .map((fk) => ({
+ id: typeof fk.id === 'number' ? fk.id : 0,
+ constraint_name: fk.name || '',
+ source_id: typeof fk.tableId === 'number' ? fk.tableId : 0,
+ source_schema: schema.trim(),
+ source_table: tableName.trim(),
+ source_columns: fk.columns.map((col: { source: string; target: string }) =>
+ col.source.trim()
+ ),
+ target_id: 0,
+ target_schema: fk.schema.trim(),
+ target_table: fk.table.trim(),
+ target_columns: fk.columns.map((col: { source: string; target: string }) =>
+ col.target.trim()
+ ),
+ deletion_action: fk.deletionAction || 'NO ACTION',
+ update_action: fk.updateAction || 'NO ACTION',
+ }))
+ }
+
+ const handleGeneratePolicies = async () => {
+ if (!project?.ref || !tableName || columns.length === 0) {
+ return toast.error(
+ 'Unable to generate policies. Please ensure table name and columns are set.'
+ )
+ }
+
+ track('rls_generate_policies_clicked')
+
+ setIsGenerating(true)
+ try {
+ const trimmedTableName = tableName.trim()
+ const trimmedSchema = schema.trim()
+
+ const newTableForeignKeys = convertForeignKeysToConstraints(foreignKeyRelations)
+
+ const allForeignKeys = [
+ ...newTableForeignKeys,
+ ...(schemaForeignKeys ?? []).filter(
+ (existingFk) =>
+ !(
+ existingFk.source_schema === trimmedSchema &&
+ existingFk.source_table === trimmedTableName
+ )
+ ),
+ ]
+
+ const tableColumns = columns.map((col) => ({ name: col.name.trim() }))
+
+ const policies = await generateStartingPoliciesForTable({
+ table: { name: trimmedTableName, schema: trimmedSchema },
+ foreignKeyConstraints: allForeignKeys,
+ columns: tableColumns,
+ projectRef: project.ref,
+ connectionString: project.connectionString,
+ enableAi: includeSchemaMetadata,
+ })
+
+ if (policies.length === 0) {
+ setGenerateFailed(true)
+ } else {
+ setGenerateFailed(false)
+ onGeneratedPoliciesChange?.(policies)
+ }
+ } catch (error: any) {
+ console.error('Failed to generate policies:', error)
+ toast.error(error.message || 'Failed to generate policies')
+ } finally {
+ setIsGenerating(false)
+ }
+ }
+
+ if (allowPolicyGeneration) {
+ return (
+ <>
+
+
+
+
+
{title}
+
{description}
+
+
+
+ {isGenerating
+ ? 'Generating policies...'
+ : generateFailed
+ ? 'Try generating again'
+ : 'Generate policies'}
+
+ {showPermissionButton && (
+
+ )}
+
+
+
+
+ setIsOptInModalOpen(false)} />
+ >
+ )
+ }
+
+ return (
+
+
+ No policies exist for this table
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx
new file mode 100644
index 00000000000..278d455c5d3
--- /dev/null
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx
@@ -0,0 +1,200 @@
+import type { PostgresTable } from '@supabase/postgres-meta'
+import { ExternalLink } from 'lucide-react'
+import Link from 'next/link'
+import { useMemo } from 'react'
+
+import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
+import { generatePolicyUpdateSQL } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
+import { DocsButton } from 'components/ui/DocsButton'
+import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
+import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { DOCS_URL } from 'lib/constants'
+import { useTrack } from 'lib/telemetry/track'
+import { Button, cn } from 'ui'
+import { Admonition } from 'ui-patterns/admonition'
+import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types'
+import { TableField } from '../TableEditor.types'
+import { PolicyList, type PolicyListItemData } from './PolicyList'
+import { PolicyListEmptyState } from './PolicyListEmptyState'
+import { ToggleRLSButton } from './ToggleRLSButton'
+
+interface RLSManagementProps {
+ table?: PostgresTable
+ tableFields: TableField // Fields within the form
+ foreignKeyRelations?: ForeignKey[] // For new tables
+ isNewRecord: boolean
+ isDuplicating: boolean
+ generatedPolicies?: GeneratedPolicy[]
+ onRLSUpdate?: (isEnabled: boolean) => void
+ onGeneratedPoliciesChange?: (policies: GeneratedPolicy[]) => void
+}
+
+export const RLSManagement = ({
+ table,
+ tableFields,
+ foreignKeyRelations = [],
+ isNewRecord,
+ isDuplicating,
+ generatedPolicies = [],
+ onRLSUpdate,
+ onGeneratedPoliciesChange,
+}: RLSManagementProps) => {
+ const track = useTrack()
+ const { data: project } = useSelectedProjectQuery()
+ const { selectedSchema } = useQuerySchemaState()
+
+ const { name: tableName, columns, isRLSEnabled } = tableFields
+ const schema = table?.schema ?? selectedSchema
+
+ const isExistingTable = !!table && !isNewRecord && !isDuplicating
+ const generatedPoliciesTargetTable = generatedPolicies[0]?.table
+ const policiesNotRelevantDueToTableNameChange =
+ isNewRecord && tableName !== generatedPoliciesTargetTable
+ const disablePoliciesList =
+ (isExistingTable && !isRLSEnabled) || policiesNotRelevantDueToTableNameChange
+
+ const { data: policies } = useDatabasePoliciesQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ },
+ {
+ enabled: !isNewRecord && !isDuplicating,
+ }
+ )
+
+ const tablePolicies = (policies ?? []).filter(
+ (policy) => policy.schema === table?.schema && policy.table === table?.name
+ )
+
+ const existingPoliciesList = useMemo(
+ () =>
+ (tablePolicies ?? []).map((policy) => ({
+ name: policy.name,
+ command: policy.action ?? policy.command,
+ sql: generatePolicyUpdateSQL(policy),
+ isNew: false,
+ })),
+ [tablePolicies]
+ )
+
+ // Convert generated policies to PolicyListItemData format
+ const generatedPoliciesList = useMemo(
+ () =>
+ generatedPolicies.map((policy) => ({
+ name: policy.name,
+ command: policy.command,
+ sql: policy.sql,
+ isNew: true,
+ })),
+ [generatedPolicies]
+ )
+
+ const allPoliciesList = useMemo(
+ () => [...existingPoliciesList, ...generatedPoliciesList],
+ [existingPoliciesList, generatedPoliciesList]
+ )
+
+ const hasPolicies = allPoliciesList.length > 0
+
+ const handleRemoveGeneratedPolicy = (index: number) => {
+ // Find the index in generatedPoliciesList that corresponds to the allPoliciesList index
+ const generatedIndex = index - existingPoliciesList.length
+ if (generatedIndex >= 0 && generatedIndex < generatedPolicies.length) {
+ const updatedPolicies = generatedPolicies.filter((_, i) => i !== generatedIndex)
+ onGeneratedPoliciesChange?.(updatedPolicies)
+
+ // Track policy removal
+ if (project?.ref) track('rls_generated_policy_removed')
+ }
+ }
+
+ return (
+
+
+
+
Policies
+
+ Set rules around who can read and write data to this table
+
+
+ {!isNewRecord && (
+
}>
+
+ Manage policies
+
+
+ )}
+
+
+ {generatedPolicies.length > 0 && policiesNotRelevantDueToTableNameChange && (
+
{
+ const updatedPolicies = generatedPolicies.map((policy) => {
+ return {
+ ...policy,
+ table: tableName,
+ name: policy.name.replaceAll(generatedPoliciesTargetTable, tableName),
+ sql: policy.sql.replaceAll(generatedPoliciesTargetTable, tableName),
+ }
+ })
+ onGeneratedPoliciesChange?.(updatedPolicies)
+ }}
+ >
+ Update policies
+
+ }
+ />
+ )}
+
+
+
+
+
+ }
+ />
+
+ {!hasPolicies ? (
+ {
+ onGeneratedPoliciesChange?.(policies)
+ }}
+ />
+ ) : (
+
+ )}
+