chore(studio): filter policies by bucket name (#40456)

* filter buckets

* Simplify

* General refactors

* Smol

* Add missing empty state for search

* Clean up 😬

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2025-11-14 17:47:16 +08:00
1 parent 8b5a087eb7
commit eaf35c14c6
18 files changed
+139 -83

No files matched your search

@@ -27,9 +27,9 @@ import { getGeneralPolicyTemplates } from './PolicyEditorModal.constants'
import PolicyEditorModalTitle from './PolicyEditorModalTitle'
interface PolicyEditorModalProps {
visible: boolean
schema: string
table: string
visible?: boolean
schema?: string
table?: string
selectedPolicyToEdit: any
showAssistantPreview?: boolean
onSelectCancel: () => void
@@ -4,10 +4,10 @@ import { useState } from 'react'
import { Badge, HoverCard, HoverCardContent, HoverCardTrigger, Input, cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { SimpleCodeBlock } from 'ui'
import CardButton from 'components/ui/CardButton'
import CopyButton from 'components/ui/CopyButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { SimpleCodeBlock } from 'ui'
import {
getGeneralPolicyTemplates,
getQueuePolicyTemplates,
@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { useParams } from 'common'
import { DocsButton } from 'components/ui/DocsButton'
import InformationBox from 'components/ui/InformationBox'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -7,7 +7,7 @@ import { useState } from 'react'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseHooksQuery } from 'data/database-triggers/database-triggers-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import InformationBox from 'components/ui/InformationBox'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -4,9 +4,9 @@ import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -94,7 +94,7 @@ export const PublicationsTables = () => {
{isSuccess &&
(tables.length === 0 ? (
<NoSearchResults />
<NoSearchResults searchString={filterString} onResetFilter={() => setFilterString('')} />
) : (
<Card>
<Table>
@@ -4,21 +4,21 @@ import { Plus, Search, X } from 'lucide-react'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useRef, useState } from 'react'
import type { PostgresRole } from '@supabase/postgres-meta'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import SparkBar from 'components/ui/SparkBar'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { CreateRolePanel } from './CreateRolePanel'
import { DeleteRoleModal } from './DeleteRoleModal'
import { RoleRow } from './RoleRow'
import { RoleRowSkeleton } from './RoleRowSkeleton'
import { SUPABASE_ROLES } from './Roles.constants'
import type { PostgresRole } from '@supabase/postgres-meta'
type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number]
@@ -6,10 +6,10 @@ import { useState } from 'react'
import { PostgresColumn } from '@supabase/postgres-meta'
import { useParams } from 'common'
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
@@ -120,7 +120,10 @@ export const ColumnList = ({
{isSuccess && (
<>
{columns.length === 0 ? (
<NoSearchResults />
<NoSearchResults
searchString={filterString}
onResetFilter={() => setFilterString('')}
/>
) : (
<div>
<Table
@@ -3,7 +3,7 @@ import { UIEvent, useMemo } from 'react'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only'
import { usePermissionsQuery } from 'data/permissions/permissions-query'
@@ -2,7 +2,7 @@ import { Search } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import AlertError from 'components/ui/AlertError'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { buttonVariants, cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
@@ -1,6 +1,8 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { difference, groupBy } from 'lodash'
import { useRouter } from 'next/router'
import { Bucket } from 'data/storage/buckets-query'
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants'
import type { StoragePolicyFormField } from './Storage.types'
@@ -20,8 +22,8 @@ const shortHash = (str: string) => {
* Output: [{ bucket: <string>, policies: <Policy[]> }]
* @param {Array} policies: All policies from a table in a schema
*/
export const formatPoliciesForStorage = (buckets: any[], policies: any[]) => {
if (policies.length === 0) return policies
export const formatPoliciesForStorage = (buckets: Bucket[], policies: PostgresPolicy[]) => {
if (policies.length === 0) return []
/**
* Format policies from storage objects to:
@@ -1,6 +1,9 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { useParams } from 'common'
import { filter, find, get, isEmpty } from 'lodash'
import { useState } from 'react'
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'
@@ -9,13 +12,16 @@ import {
ScaffoldSectionDescription,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
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 { 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 { formatPoliciesForStorage } from '../Storage.utils'
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
@@ -23,18 +29,24 @@ import StoragePoliciesEditPolicyModal from './StoragePoliciesEditPolicyModal'
import StoragePoliciesPlaceholder from './StoragePoliciesPlaceholder'
export const StoragePolicies = () => {
const { data: project } = useSelectedProjectQuery()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data, isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef })
const buckets = data ?? []
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy>()
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<PostgresPolicy>()
const [isEditingPolicyForBucket, setIsEditingPolicyForBucket] = useState<{
bucket: string
table: string
}>()
const [searchString, setSearchString] = useQueryState(
'search',
parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
)
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<any>({})
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<any>({})
const [isEditingPolicyForBucket, setIsEditingPolicyForBucket] = useState<any>({})
const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef })
const {
data: policiesData,
data: policies = [],
refetch,
isLoading: isLoadingPolicies,
} = useDatabasePoliciesQuery({
@@ -42,7 +54,6 @@ export const StoragePolicies = () => {
connectionString: project?.connectionString,
schema: 'storage',
})
const policies = policiesData ?? []
const isLoading = isLoadingBuckets || isLoadingPolicies
@@ -54,7 +65,7 @@ export const StoragePolicies = () => {
onSuccess: async () => {
await refetch()
toast.success('Successfully deleted policy!')
setSelectedPolicyToDelete({})
setSelectedPolicyToDelete(undefined)
},
})
@@ -62,24 +73,50 @@ export const StoragePolicies = () => {
const showStoragePolicyEditor =
isEmpty(selectedPolicyToEdit) &&
!isEmpty(isEditingPolicyForBucket) &&
get(isEditingPolicyForBucket, ['bucket'], '').length > 0
(isEditingPolicyForBucket.bucket ?? '').length > 0
const showGeneralPolicyEditor = !isEmpty(isEditingPolicyForBucket) && !showStoragePolicyEditor
// Policies under storage.objects
const storageObjectsPolicies = filter(policies, { table: 'objects' })
const formattedStorageObjectPolicies = formatPoliciesForStorage(buckets, storageObjectsPolicies)
const ungroupedPolicies = get(
find(formattedStorageObjectPolicies, { name: 'Ungrouped' }),
['policies'],
[]
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 = filter(policies, { table: 'buckets' })
const storageBucketPolicies = policies.filter(
(x) => x.schema === 'storage' && x.table === 'buckets'
)
/**
* 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() || ''
// Filter buckets by name if search filter is present
const filteredBucketsList = searchFilter
? buckets.filter((bucket) => bucket.name.toLowerCase().includes(searchFilter))
: buckets
// 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 =
formattedStorageObjectPolicies.find((x) => x.name === bucket.name)?.policies ?? []
return { bucket, policies }
})
// Schema-level policies should always be shown, unaffected by search filter
return filteredBucketsWithPoliciesList
}, [buckets, searchString, formattedStorageObjectPolicies])
const onSelectPolicyAdd = (bucketName = '', table = '') => {
setSelectedPolicyToEdit({})
setSelectedPolicyToEdit(undefined)
setIsEditingPolicyForBucket({ bucket: bucketName, table })
}
@@ -89,11 +126,11 @@ export const StoragePolicies = () => {
}
const onCancelPolicyEdit = () => {
setIsEditingPolicyForBucket({})
setIsEditingPolicyForBucket(undefined)
}
const onSelectPolicyDelete = (policy: any) => setSelectedPolicyToDelete(policy)
const onCancelPolicyDelete = () => setSelectedPolicyToDelete({})
const onCancelPolicyDelete = () => setSelectedPolicyToDelete(undefined)
const onSavePolicySuccess = async () => {
toast.success('Successfully saved policy!')
@@ -155,6 +192,10 @@ export const StoragePolicies = () => {
console.error('Project is required')
return true
}
if (!selectedPolicyToEdit) {
console.error('Unable to find policy')
return true
}
try {
await updateDatabasePolicy({
@@ -172,6 +213,8 @@ export const StoragePolicies = () => {
const onDeletePolicy = async () => {
if (!project) return console.error('Project is required')
if (!selectedPolicyToDelete) return console.error('Unable to find policy')
deleteDatabasePolicy({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -180,7 +223,7 @@ export const StoragePolicies = () => {
}
return (
<div className="flex min-h-full w-full flex-col">
<>
{isLoading ? (
<ScaffoldSection isFullWidth>
<GenericSkeletonLoader />
@@ -194,22 +237,51 @@ export const StoragePolicies = () => {
</ScaffoldSectionDescription>
{buckets.length === 0 && <StoragePoliciesPlaceholder />}
{buckets.length > 0 && (
<div className="mb-4">
<Input
size="tiny"
placeholder="Filter buckets"
className="block"
containerClassName="w-full lg:w-52 [&>div>svg]:-mt-0.5"
value={searchString || ''}
onChange={(e) => {
const str = e.target.value
setSearchString(str)
}}
icon={<Search size={14} />}
actions={
searchString ? (
<Button
size="tiny"
type="text"
className="p-0 h-5 w-5"
icon={<X />}
onClick={() => setSearchString('')}
/>
) : null
}
/>
</div>
)}
{searchString.length > 0 && filteredBucketsWithPolicies.length === 0 && (
<NoSearchResults
searchString={searchString}
onResetFilter={() => setSearchString('')}
/>
)}
{/* Sections for policies grouped by buckets */}
<div className="flex flex-col gap-y-4">
{buckets.map((bucket) => {
const bucketPolicies = get(
find(formattedStorageObjectPolicies, { name: bucket.name }),
['policies'],
[]
).sort((a: any, b: any) => a.name.localeCompare(b.name))
{filteredBucketsWithPolicies.map(({ bucket, policies }) => {
return (
<StoragePoliciesBucketRow
key={bucket.name}
table="objects"
label={bucket.name}
bucket={bucket}
policies={bucketPolicies}
policies={policies}
onSelectPolicyAdd={onSelectPolicyAdd}
onSelectPolicyEdit={onSelectPolicyEdit}
onSelectPolicyDelete={onSelectPolicyDelete}
@@ -253,7 +325,7 @@ export const StoragePolicies = () => {
{/* Only used for adding policies to buckets */}
<StoragePoliciesEditPolicyModal
visible={showStoragePolicyEditor}
bucketName={isEditingPolicyForBucket.bucket}
bucketName={isEditingPolicyForBucket?.bucket}
onSelectCancel={onCancelPolicyEdit}
onCreatePolicies={onCreatePolicies}
onSaveSuccess={onSavePolicySuccess}
@@ -263,7 +335,7 @@ export const StoragePolicies = () => {
<PolicyEditorModal
schema="storage"
visible={showGeneralPolicyEditor}
table={isEditingPolicyForBucket.table}
table={isEditingPolicyForBucket?.table ?? ''}
selectedPolicyToEdit={selectedPolicyToEdit}
onSelectCancel={onCancelPolicyEdit}
onCreatePolicy={onCreatePolicy}
@@ -275,12 +347,12 @@ export const StoragePolicies = () => {
danger
visible={!isEmpty(selectedPolicyToDelete)}
title="Confirm to delete policy"
description={`This is permanent! Are you sure you want to delete the policy "${selectedPolicyToDelete.name}"`}
description={`This is permanent! Are you sure you want to delete the policy "${selectedPolicyToDelete?.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={onCancelPolicyDelete}
onSelectConfirm={onDeletePolicy}
/>
</div>
</>
)
}
@@ -1,23 +0,0 @@
import { BASE_PATH } from 'lib/constants'
import SVG from 'react-inlinesvg'
/**
* To be deprecated in favor of NoSearchResults in components/ui
*/
export const NoSearchResults = () => {
return (
<div className="flex h-64 flex-col items-center justify-center">
<SVG
src={`${BASE_PATH}/img/no-search-results.svg`}
preProcessor={(code) =>
code.replace(/svg/, 'svg className="mb-2 w-16 h-16 text-color-inherit"')
}
/>
<p className="w-64 text-center text-sm opacity-50">
Hmm, we couldn't find any results that match your query.
</p>
</div>
)
}
export default NoSearchResults
@@ -32,5 +32,3 @@ export const NoSearchResults = ({
</div>
)
}
export default NoSearchResults
@@ -7,7 +7,7 @@ import { PROJECT_STATUS } from 'lib/constants'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { databasePoliciesKeys } from './keys'
export type DatabasePoliciesVariables = {
type DatabasePoliciesVariables = {
projectRef?: string
connectionString?: string | null
schema?: string
+1 -1
View File
@@ -10,7 +10,7 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { withAuth } from 'hooks/misc/withAuth'
@@ -13,7 +13,7 @@ import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { DocsButton } from 'components/ui/DocsButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { NoSearchResults } from 'components/ui/NoSearchResults'
import { DOCS_URL } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { Input } from 'ui-patterns/DataInputs/Input'
@@ -100,7 +100,11 @@ const BucketPage: NextPageWithLayout = () => {
) : undefined
}
>
<Link href={`/project/${ref}/storage/files/policies`}>Policies</Link>
<Link
href={`/project/${ref}/storage/files/policies?search=${encodeURIComponent(bucket?.name ?? '')}`}
>
Policies
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>