mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
[FE-2158] – feat(studio): Add exposed tables config to Postgrest settings (#43280)
Adds a feature flagged exposed tables config to postgrest settings: <img width="1158" height="589" alt="Screenshot 2026-03-02 at 17 04 13" src="https://github.com/user-attachments/assets/8fa8ab81-0bfa-4781-83bc-80ac52e180f8" /> To test: - make sure the existing (feature flag off) settings work as expected - make sure exposing and removing schemas works in the new mode - make sure exposing and removing tables works in the new mode - ideally try with a lot of tables to check search and infinite scroll work as expected - try exposing a schema without any tables (like `graphql_public`) and make sure it doesn't get removed randomly when editing tables - try with a table with custom permissions (for example `REVOKE SELECT ON public.posts FROM anon;`) and make sure the user is informed with the tooltip --------- Co-authored-by: Nick Babadzhanian <33933459+pgnickb@users.noreply.github.com>
This commit is contained in:
1 parent
4b994fcb54
commit
5894e37b40
12 files changed
+1201
-415
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
import { pluralize } from '@/lib/helpers'
|
||||
|
||||
interface ExposedSchemaSelectorProps {
|
||||
disabled?: boolean
|
||||
selectedSchemas: string[]
|
||||
onToggleSchema: (schema: string) => void
|
||||
}
|
||||
|
||||
export const ExposedSchemaSelector = ({
|
||||
disabled = false,
|
||||
selectedSchemas,
|
||||
onToggleSchema,
|
||||
}: ExposedSchemaSelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
data: allSchemas,
|
||||
isPending,
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useSchemasQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const schemas = useMemo(
|
||||
() =>
|
||||
(allSchemas ?? [])
|
||||
.filter((s) => {
|
||||
if (s.name === 'graphql_public') return true
|
||||
return !INTERNAL_SCHEMAS.includes(s.name)
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[allSchemas]
|
||||
)
|
||||
|
||||
const selectedSet = useMemo(() => new Set(selectedSchemas), [selectedSchemas])
|
||||
const selectedCount = schemas.filter((s) => selectedSet.has(s.name)).length
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className="w-full [&>span]:w-full !pr-1 space-x-1"
|
||||
iconRight={<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">
|
||||
{isSuccess
|
||||
? `${selectedCount} of ${schemas.length} ${pluralize(schemas.length, 'schema')} exposed`
|
||||
: 'Loading schemas...'}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ className="text-xs" placeholder="Find schema..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
<div className="px-2 py-1 w-4/5">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="flex items-center py-3 justify-center">
|
||||
<p className="text-xs text-foreground-lighter">Failed to retrieve schemas</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty_Shadcn_>
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No schemas found
|
||||
</p>
|
||||
</CommandEmpty_Shadcn_>
|
||||
<ScrollArea className={schemas.length > 7 ? 'h-[210px]' : ''}>
|
||||
{schemas.map((schema) => {
|
||||
const isExposed = selectedSet.has(schema.name)
|
||||
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
className="cursor-pointer w-full"
|
||||
onSelect={() => {
|
||||
onToggleSchema(schema.name)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn('w-full flex items-center gap-x-2', !isExposed && 'ml-6')}
|
||||
>
|
||||
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
||||
<span className="truncate">{schema.name}</span>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks'
|
||||
import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
ScrollArea,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query'
|
||||
import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { pluralize } from '@/lib/helpers'
|
||||
|
||||
interface ExposedTableSelectorProps {
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
selectedSchemas: string[]
|
||||
pendingAddTableIds: number[]
|
||||
pendingRemoveTableIds: number[]
|
||||
onTogglePendingAdd: (tableId: number) => void
|
||||
onTogglePendingRemove: (tableId: number) => void
|
||||
}
|
||||
|
||||
export const ExposedTableSelector = ({
|
||||
className,
|
||||
disabled = false,
|
||||
selectedSchemas,
|
||||
pendingAddTableIds,
|
||||
pendingRemoveTableIds,
|
||||
onTogglePendingAdd,
|
||||
onTogglePendingRemove,
|
||||
}: ExposedTableSelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const scrollRootRef = useRef<HTMLDivElement | null>(null)
|
||||
const [sentinelRef, entry] = useIntersectionObserver({
|
||||
root: scrollRootRef.current,
|
||||
threshold: 0,
|
||||
rootMargin: '0px',
|
||||
})
|
||||
|
||||
const { data: countsData, isPending: isCountsPending } = useQuery({
|
||||
...exposedTableCountsQueryOptions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
selectedSchemas,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length
|
||||
|
||||
const totalCount = countsData?.total_count ?? 0
|
||||
const grantsCount = countsData?.grants_count ?? 0
|
||||
|
||||
const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
...exposedTablesInfiniteQueryOptions({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
search: search.length === 0 ? undefined : debouncedSearch || undefined,
|
||||
}),
|
||||
placeholderData: search.length > 0 ? keepPreviousData : undefined,
|
||||
})
|
||||
|
||||
const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages])
|
||||
|
||||
const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds])
|
||||
const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
type="default"
|
||||
className="w-full [&>span]:w-full !pr-1 space-x-1"
|
||||
iconRight={
|
||||
<ChevronsUpDown className="text-foreground-muted" strokeWidth={2} size={14} />
|
||||
}
|
||||
>
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">
|
||||
{isCountsPending
|
||||
? 'Loading tables...'
|
||||
: `${grantsCount} of ${totalCount} tables exposed${
|
||||
pendingCount > 0
|
||||
? `, ${pendingCount} pending ${pluralize(pendingCount, 'change')}`
|
||||
: ''
|
||||
}`}
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_
|
||||
className="p-0 min-w-[200px] pointer-events-auto"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sameWidthAsTrigger
|
||||
>
|
||||
<Command_Shadcn_ shouldFilter={false}>
|
||||
<CommandInput_Shadcn_
|
||||
className="text-xs"
|
||||
placeholder="Find table..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
<div className="px-2 py-1 w-4/5">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
</>
|
||||
) : isError ? (
|
||||
<div className="flex items-center py-3 justify-center">
|
||||
<p className="text-xs text-foreground-lighter">Failed to retrieve tables</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{search.length > 0 && tables.length === 0 && (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No tables found
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea
|
||||
ref={scrollRootRef}
|
||||
className={tables.length > 7 ? 'h-[210px]' : ''}
|
||||
>
|
||||
{tables.map((table) => {
|
||||
const isSchemaExposed = selectedSchemas.includes(table.schema)
|
||||
const hasPendingAdd = pendingAddSet.has(table.id)
|
||||
const hasPendingRemove = pendingRemoveSet.has(table.id)
|
||||
|
||||
const isCustomTable = table.status === 'custom'
|
||||
const isGranted = table.status === 'granted'
|
||||
|
||||
const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove
|
||||
const isExposed =
|
||||
isSchemaExposed &&
|
||||
(isCustomTable
|
||||
? hasPendingAdd
|
||||
: isGranted
|
||||
? !hasPendingRemove
|
||||
: hasPendingAdd)
|
||||
|
||||
const customGrantsTooltip = getCustomGrantsTooltip({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
})
|
||||
|
||||
return (
|
||||
<CommandItem_Shadcn_
|
||||
key={table.id}
|
||||
value={`${table.schema}.${table.name}-${table.id}`}
|
||||
className={cn(
|
||||
'w-full',
|
||||
isSchemaExposed ? 'cursor-pointer' : 'opacity-50 !cursor-not-allowed'
|
||||
)}
|
||||
onSelect={() => {
|
||||
if (!isSchemaExposed) return
|
||||
|
||||
if (isCustomTable) {
|
||||
if (hasPendingAdd) {
|
||||
onTogglePendingAdd(table.id)
|
||||
onTogglePendingRemove(table.id)
|
||||
} else if (hasPendingRemove) {
|
||||
onTogglePendingRemove(table.id)
|
||||
onTogglePendingAdd(table.id)
|
||||
} else {
|
||||
onTogglePendingAdd(table.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isGranted) {
|
||||
onTogglePendingRemove(table.id)
|
||||
} else {
|
||||
onTogglePendingAdd(table.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex items-center gap-x-2">
|
||||
<div className="w-4 shrink-0 flex items-center justify-center">
|
||||
{isExposed && <Check size={16} className="text-brand shrink-0" />}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
(!isSchemaExposed || isCustomNeutral) && 'text-foreground-muted',
|
||||
isCustomNeutral && isSchemaExposed && 'text-warning'
|
||||
)}
|
||||
>
|
||||
{`${table.schema}.${table.name}`}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
{isCustomTable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 flex items-center justify-center hover:text-foreground-light',
|
||||
isCustomNeutral && isSchemaExposed
|
||||
? 'text-warning'
|
||||
: 'text-foreground-muted'
|
||||
)}
|
||||
>
|
||||
<CircleAlert size={14} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[320px] text-xs pointer-events-none"
|
||||
>
|
||||
{customGrantsTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isSchemaExposed && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Schema not exposed"
|
||||
className="inline-flex items-center text-foreground-muted hover:text-foreground-light"
|
||||
>
|
||||
<Info size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[320px] text-xs">
|
||||
{`The schema "${table.schema}" must be exposed before enabling this table.`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
)
|
||||
})}
|
||||
<div ref={sentinelRef} className="h-1 -mt-1" />
|
||||
{hasNextPage && (
|
||||
<div className="px-2 py-1">
|
||||
<ShimmeringLoader className="py-2" />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getCustomGrantsTooltip = ({
|
||||
hasPendingAdd,
|
||||
hasPendingRemove,
|
||||
}: {
|
||||
hasPendingAdd: boolean
|
||||
hasPendingRemove: boolean
|
||||
}) => {
|
||||
if (hasPendingAdd) {
|
||||
return 'This table has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.'
|
||||
}
|
||||
|
||||
if (hasPendingRemove) {
|
||||
return 'This table has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.'
|
||||
}
|
||||
|
||||
return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.'
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { indexOf } from 'lodash'
|
||||
import { Lock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Input_Shadcn_,
|
||||
PrePostTab,
|
||||
Skeleton,
|
||||
useWatch_Shadcn_,
|
||||
} from 'ui'
|
||||
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
@@ -32,16 +33,26 @@ import {
|
||||
} from 'ui-patterns/multi-select'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { ExposedSchemaSelector } from './ExposedSchemaSelector'
|
||||
import { HardenAPIModal } from './HardenAPIModal'
|
||||
import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector'
|
||||
import { FormActions } from '@/components/ui/Forms/FormActions'
|
||||
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
|
||||
import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useSchemasQuery } from '@/data/database/schemas-query'
|
||||
import { privilegeKeys } from '@/data/privileges/keys'
|
||||
import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled'
|
||||
import useLatest from '@/hooks/misc/useLatest'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
import { noop } from '@/lib/void'
|
||||
import type { ResponseError } from '@/types'
|
||||
|
||||
const formSchema = z.object({
|
||||
// Fields for updatePostgrestConfig
|
||||
dbSchema: z.array(z.string()),
|
||||
dbExtraSearchPath: z.array(z.string()),
|
||||
maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
|
||||
@@ -51,11 +62,17 @@ const formSchema = z.object({
|
||||
.max(1000, "Can't be more than 1000")
|
||||
.optional()
|
||||
.nullable(),
|
||||
|
||||
// Fields for expose toggles
|
||||
tableIdsToAdd: z.array(z.number()),
|
||||
tableIdsToRemove: z.array(z.number()),
|
||||
})
|
||||
|
||||
export const PostgrestConfig = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const queryClient = useQueryClient()
|
||||
const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
@@ -63,6 +80,7 @@ export const PostgrestConfig = () => {
|
||||
data: config,
|
||||
isError,
|
||||
isPending: isLoadingConfig,
|
||||
isSuccess: isSuccessConfig,
|
||||
} = useProjectPostgrestConfigQuery({ projectRef })
|
||||
const { data: extensions } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
@@ -77,27 +95,36 @@ export const PostgrestConfig = () => {
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const configDbSchemas = useMemo(
|
||||
() => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
|
||||
[config?.db_schema]
|
||||
)
|
||||
|
||||
const isLoading = isLoadingConfig || isLoadingSchemas
|
||||
|
||||
const { mutate: updatePostgrestConfig, isPending: isUpdating } =
|
||||
useProjectPostgrestConfigUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully saved settings')
|
||||
},
|
||||
})
|
||||
const schemas = useMemo(
|
||||
() =>
|
||||
allSchemas
|
||||
.filter((x) => !INTERNAL_SCHEMAS.some((schema) => schema === x.name))
|
||||
.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
value: x.name,
|
||||
name: x.name,
|
||||
disabled: false,
|
||||
}
|
||||
}) ?? [],
|
||||
[allSchemas]
|
||||
)
|
||||
|
||||
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation()
|
||||
|
||||
const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation()
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
const formId = 'project-postgres-config'
|
||||
const hiddenSchema = [
|
||||
'auth',
|
||||
'pgbouncer',
|
||||
'hooks',
|
||||
'extensions',
|
||||
'vault',
|
||||
'storage',
|
||||
'realtime',
|
||||
'pgsodium',
|
||||
'pgsodium_masks',
|
||||
]
|
||||
|
||||
const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } =
|
||||
useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
|
||||
|
||||
@@ -105,17 +132,19 @@ export const PostgrestConfig = () => {
|
||||
(extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : []
|
||||
return {
|
||||
dbSchema,
|
||||
dbSchema: configDbSchemas,
|
||||
maxRows: config?.max_rows,
|
||||
// TODO: only display schemas that exist in the db
|
||||
dbExtraSearchPath: (config?.db_extra_search_path ?? '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
|
||||
.filter(Boolean),
|
||||
dbPool: config?.db_pool,
|
||||
tableIdsToAdd: [] as number[],
|
||||
tableIdsToRemove: [] as number[],
|
||||
}
|
||||
}, [config, allSchemas])
|
||||
}, [config, configDbSchemas])
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -123,43 +152,81 @@ export const PostgrestConfig = () => {
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
const schemas =
|
||||
allSchemas
|
||||
.filter((x) => {
|
||||
const find = indexOf(hiddenSchema, x.name)
|
||||
if (find < 0) return x
|
||||
})
|
||||
.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
value: x.name,
|
||||
name: x.name,
|
||||
disabled: false,
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
form.reset({ ...defaultValues })
|
||||
}, [form, defaultValues])
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!projectRef) return console.error('Project ref is required') // is this needed ?
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
|
||||
updatePostgrestConfig({
|
||||
projectRef,
|
||||
dbSchema: values.dbSchema.join(', '),
|
||||
maxRows: values.maxRows,
|
||||
dbExtraSearchPath: values.dbExtraSearchPath.join(','),
|
||||
dbPool: values.dbPool ? values.dbPool : null,
|
||||
})
|
||||
setIsUpdating(true)
|
||||
|
||||
try {
|
||||
let dbSchema = values.dbSchema.join(',')
|
||||
|
||||
if (isApiGrantTogglesEnabled) {
|
||||
await updateExposedTables({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
tableIdsToAdd: values.tableIdsToAdd,
|
||||
tableIdsToRemove: values.tableIdsToRemove,
|
||||
})
|
||||
}
|
||||
|
||||
await updatePostgrestConfig(
|
||||
{
|
||||
projectRef,
|
||||
dbSchema,
|
||||
maxRows: values.maxRows,
|
||||
dbExtraSearchPath: values.dbExtraSearchPath.join(','),
|
||||
dbPool: values.dbPool ? values.dbPool : null,
|
||||
},
|
||||
{ onError: noop }
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.exposedTablesInfinite(projectRef),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema),
|
||||
}),
|
||||
])
|
||||
|
||||
toast.success('Successfully saved settings')
|
||||
form.reset({
|
||||
dbSchema: dbSchema
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
maxRows: values.maxRows,
|
||||
dbExtraSearchPath: values.dbExtraSearchPath,
|
||||
dbPool: values.dbPool,
|
||||
tableIdsToAdd: [],
|
||||
tableIdsToRemove: [],
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error')
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetFormRef = useLatest(resetForm)
|
||||
const isReady = isSuccessConfig && isSuccessSchemas
|
||||
useEffect(() => {
|
||||
if (config && isSuccessSchemas) {
|
||||
resetForm()
|
||||
if (isReady) {
|
||||
resetFormRef.current()
|
||||
}
|
||||
}, [config, isSuccessSchemas, resetForm])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isReady])
|
||||
|
||||
const watchedDbSchema = useWatch_Shadcn_({ control: form.control, name: 'dbSchema' })
|
||||
const watchedTableIdsToAdd = useWatch_Shadcn_({ control: form.control, name: 'tableIdsToAdd' })
|
||||
const watchedTableIdsToRemove = useWatch_Shadcn_({
|
||||
control: form.control,
|
||||
name: 'tableIdsToRemove',
|
||||
})
|
||||
return (
|
||||
<PageSection id="postgrest-config" className="first:pt-0">
|
||||
<PageSectionContent>
|
||||
@@ -176,88 +243,170 @@ export const PostgrestConfig = () => {
|
||||
</CardContent>
|
||||
) : (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="dbSchema"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_>
|
||||
<FormItemLayout
|
||||
label="Exposed schemas"
|
||||
description="The schemas to expose in your API. Tables, views and stored procedures in
|
||||
these schemas will get API endpoints."
|
||||
layout="flex-row-reverse"
|
||||
>
|
||||
{isLoadingSchemas ? (
|
||||
<div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
|
||||
<Skeleton className="w-full h-[38px]" />
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelector
|
||||
onValuesChange={field.onChange}
|
||||
values={field.value}
|
||||
size="small"
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
mode="inline-combobox"
|
||||
label="Select schemas..."
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
deletableBadge
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{schemas.length <= 0 ? (
|
||||
<MultiSelectorItem key="empty" value="no">
|
||||
no
|
||||
</MultiSelectorItem>
|
||||
) : (
|
||||
schemas.map((x) => (
|
||||
<MultiSelectorItem key={x.id + '-' + x.name} value={x.name}>
|
||||
{x.name}
|
||||
</MultiSelectorItem>
|
||||
))
|
||||
)}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
{!field.value.includes('public') && field.value.length > 0 && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="The public schema for this project is not exposed"
|
||||
className="mt-2"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm">
|
||||
You will not be able to query tables and views in the{' '}
|
||||
<code className="text-code-inline">public</code> schema via
|
||||
supabase-js or HTTP clients.
|
||||
</p>
|
||||
{isGraphqlExtensionEnabled && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Tables in the{' '}
|
||||
<code className="text-code-inline">public</code> schema are
|
||||
still exposed over our GraphQL endpoints.
|
||||
</p>
|
||||
<Button asChild type="default" className="mt-2">
|
||||
<Link href={`/project/${projectRef}/database/extensions`}>
|
||||
Disable the pg_graphql extension
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormItem_Shadcn_>
|
||||
{isApiGrantTogglesEnabled ? (
|
||||
<CardContent className="space-y-6">
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex-row-reverse"
|
||||
label="Exposed schemas"
|
||||
description="Select schemas to include in the Data API. Schemas must be included before tables can be exposed."
|
||||
>
|
||||
<ExposedSchemaSelector
|
||||
selectedSchemas={watchedDbSchema}
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
onToggleSchema={(schema) => {
|
||||
const current = form.getValues('dbSchema')
|
||||
if (current.includes(schema)) {
|
||||
form.setValue(
|
||||
'dbSchema',
|
||||
current.filter((x) => x !== schema),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
} else {
|
||||
form.setValue('dbSchema', [...current, schema], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
<FormItemLayout
|
||||
isReactForm={false}
|
||||
layout="flex-row-reverse"
|
||||
label="Exposed tables"
|
||||
description="Toggle Data API access for individual tables."
|
||||
>
|
||||
<ExposedTableSelector
|
||||
selectedSchemas={watchedDbSchema}
|
||||
pendingAddTableIds={watchedTableIdsToAdd}
|
||||
pendingRemoveTableIds={watchedTableIdsToRemove}
|
||||
onTogglePendingAdd={(tableId) => {
|
||||
const current = form.getValues('tableIdsToAdd')
|
||||
if (current.includes(tableId)) {
|
||||
form.setValue(
|
||||
'tableIdsToAdd',
|
||||
current.filter((x) => x !== tableId),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
} else {
|
||||
form.setValue('tableIdsToAdd', [...current, tableId], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onTogglePendingRemove={(tableId) => {
|
||||
const current = form.getValues('tableIdsToRemove')
|
||||
if (current.includes(tableId)) {
|
||||
form.setValue(
|
||||
'tableIdsToRemove',
|
||||
current.filter((x) => x !== tableId),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
} else {
|
||||
form.setValue('tableIdsToRemove', [...current, tableId], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormItemLayout>
|
||||
|
||||
{watchedDbSchema.length === 0 && (
|
||||
<Admonition
|
||||
type="warning"
|
||||
title="No schema is currently selected"
|
||||
description="Saving with no selected schema or table will disable the Data API."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardContent>
|
||||
) : (
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="dbSchema"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_>
|
||||
<FormItemLayout
|
||||
label="Exposed schemas"
|
||||
description="The schemas to expose in your API. Tables, views and stored procedures in
|
||||
these schemas will get API endpoints."
|
||||
layout="flex-row-reverse"
|
||||
>
|
||||
{isLoadingSchemas ? (
|
||||
<div className="col-span-12 flex flex-col gap-2 lg:col-span-7">
|
||||
<Skeleton className="w-full h-[38px]" />
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelector
|
||||
onValuesChange={field.onChange}
|
||||
values={field.value}
|
||||
size="small"
|
||||
disabled={!canUpdatePostgrestConfig}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
mode="inline-combobox"
|
||||
label="Select schemas..."
|
||||
badgeLimit="wrap"
|
||||
showIcon={false}
|
||||
deletableBadge
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{schemas.length <= 0 ? (
|
||||
<MultiSelectorItem key="empty" value="no">
|
||||
no
|
||||
</MultiSelectorItem>
|
||||
) : (
|
||||
schemas.map((x) => (
|
||||
<MultiSelectorItem
|
||||
key={x.id + '-' + x.name}
|
||||
value={x.name}
|
||||
>
|
||||
{x.name}
|
||||
</MultiSelectorItem>
|
||||
))
|
||||
)}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
{!field.value.includes('public') && field.value.length > 0 && (
|
||||
<Admonition
|
||||
type="default"
|
||||
title="The public schema for this project is not exposed"
|
||||
className="mt-2"
|
||||
description={
|
||||
<>
|
||||
<p className="text-sm">
|
||||
You will not be able to query tables and views in the{' '}
|
||||
<code className="text-code-inline">public</code> schema via
|
||||
supabase-js or HTTP clients.
|
||||
</p>
|
||||
{isGraphqlExtensionEnabled && (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Tables in the{' '}
|
||||
<code className="text-code-inline">public</code> schema
|
||||
are still exposed over our GraphQL endpoints.
|
||||
</p>
|
||||
<Button asChild type="default" className="mt-2">
|
||||
<Link href={`/project/${projectRef}/database/extensions`}>
|
||||
Disable the pg_graphql extension
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
|
||||
+21
-112
@@ -3,7 +3,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
|
||||
import { useIsSchemaExposed } from 'hooks/misc/useIsSchemaExposed'
|
||||
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
useEffect,
|
||||
@@ -15,39 +15,22 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
import { usePreviousDistinct } from 'react-use'
|
||||
import { Button, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Switch } from 'ui'
|
||||
import { Button, Switch } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import {
|
||||
MultiSelector,
|
||||
MultiSelectorContent,
|
||||
MultiSelectorItem,
|
||||
MultiSelectorList,
|
||||
MultiSelectorTrigger,
|
||||
} from 'ui-patterns/multi-select'
|
||||
|
||||
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
||||
import { useTableApiAccessQuery } from '@/data/privileges/table-api-access-query'
|
||||
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
|
||||
import {
|
||||
API_ACCESS_ROLES,
|
||||
API_PRIVILEGE_TYPES,
|
||||
checkDataApiPrivilegesNonEmpty,
|
||||
DEFAULT_DATA_API_PRIVILEGES,
|
||||
EMPTY_DATA_API_PRIVILEGES,
|
||||
isApiPrivilegeType,
|
||||
type ApiAccessRole,
|
||||
type ApiPrivilegesByRole,
|
||||
} from '@/lib/data-api-types'
|
||||
import type { DeepReadonly, Prettify } from '@/lib/type-helpers'
|
||||
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
||||
|
||||
const ROLE_LABELS: Record<ApiAccessRole, string> = {
|
||||
anon: 'Anonymous (anon)',
|
||||
authenticated: 'Authenticated',
|
||||
}
|
||||
|
||||
namespace ApiAccessToggleProps {
|
||||
type New = {
|
||||
type: 'new'
|
||||
@@ -255,10 +238,11 @@ export const useTableApiAccessHandlerWithHistory = (
|
||||
}
|
||||
}
|
||||
|
||||
type ApiAccessToggleProps = {
|
||||
type ApiAccessToggleComponentProps = {
|
||||
projectRef?: string
|
||||
schemaName?: string
|
||||
tableName?: string
|
||||
isNewRecord: boolean
|
||||
handler: TableApiAccessHandlerWithHistoryReturn
|
||||
}
|
||||
|
||||
@@ -266,10 +250,9 @@ export const ApiAccessToggle = ({
|
||||
projectRef,
|
||||
schemaName,
|
||||
tableName,
|
||||
isNewRecord,
|
||||
handler,
|
||||
}: ApiAccessToggleProps): ReactNode => {
|
||||
const [isPrivilegesPopoverOpen, setIsPrivilegesPopoverOpen] = useState(false)
|
||||
|
||||
}: ApiAccessToggleComponentProps): ReactNode => {
|
||||
const isPending = handler.isPending
|
||||
const isError = handler.isError
|
||||
const isSchemaExposed = handler.data?.schemaExposed
|
||||
@@ -290,104 +273,33 @@ export const ApiAccessToggle = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrivilegesChange = (role: ApiAccessRole) => (values: string[]) => {
|
||||
if (!handler.isSuccess) return
|
||||
if (!isSchemaExposed) return
|
||||
if (!privileges) return
|
||||
|
||||
handler.data?.setPrivileges((oldPrivileges) => {
|
||||
return {
|
||||
...oldPrivileges,
|
||||
[role]: values.filter(isApiPrivilegeType),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const totalAvailablePrivileges = API_ACCESS_ROLES.length * API_PRIVILEGE_TYPES.length
|
||||
const totalSelectedPrivileges = Object.values(privileges ?? {}).reduce(
|
||||
(sum, rolePrivileges) => sum + rolePrivileges.length,
|
||||
0
|
||||
)
|
||||
const hasPartialPrivileges =
|
||||
totalSelectedPrivileges > 0 && totalSelectedPrivileges < totalAvailablePrivileges
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-foreground flex items-center gap-1.5">
|
||||
Data API Access
|
||||
<InfoTooltip side="top" className="max-w-80">
|
||||
This controls which operations the <code className="text-code-inline">anon</code>{' '}
|
||||
and <code className="text-code-inline whitespace-nowrap">authenticated</code> roles
|
||||
can perform on this table via the Data API. Unselected privileges are revoked from
|
||||
these roles.
|
||||
</InfoTooltip>
|
||||
</p>
|
||||
<div>
|
||||
<h5>Data API Access</h5>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Allow this table to be queried via Supabase client libraries or the API directly
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover_Shadcn_
|
||||
open={isPrivilegesPopoverOpen}
|
||||
onOpenChange={setIsPrivilegesPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger_Shadcn_ asChild disabled={isDisabled || !hasNonEmptyPrivileges}>
|
||||
<Button
|
||||
type="text"
|
||||
className="w-6 h-6 p-0 text-foreground-light"
|
||||
aria-label="Configure API privileges"
|
||||
>
|
||||
<Settings strokeWidth={1.5} size={16} />
|
||||
{hasPartialPrivileges && (
|
||||
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-foreground shadow-sm" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ align="end" className="w-[420px] space-y-3">
|
||||
{!isDisabled && (
|
||||
<>
|
||||
<p className="text-sm text-foreground">Adjust API privileges per role</p>
|
||||
<div className="space-y-2">
|
||||
{API_ACCESS_ROLES.map((role) => (
|
||||
<div key={role} className="space-y-2">
|
||||
<p className="text-sm text-foreground flex items-center gap-1.5">
|
||||
{ROLE_LABELS[role]}
|
||||
</p>
|
||||
<MultiSelector
|
||||
values={(privileges?.[role] as string[]) ?? []}
|
||||
onValuesChange={handlePrivilegesChange(role)}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
label="Select privileges"
|
||||
badgeLimit={4}
|
||||
deletableBadge={true}
|
||||
/>
|
||||
<MultiSelectorContent>
|
||||
<MultiSelectorList>
|
||||
{API_PRIVILEGE_TYPES.map((privilege) => (
|
||||
<MultiSelectorItem key={privilege} value={privilege}>
|
||||
{privilege}
|
||||
</MultiSelectorItem>
|
||||
))}
|
||||
</MultiSelectorList>
|
||||
</MultiSelectorContent>
|
||||
</MultiSelector>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
{isNewRecord ? (
|
||||
<Switch
|
||||
checked={hasNonEmptyPrivileges}
|
||||
onCheckedChange={handleMasterToggle}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/project/${projectRef}/integrations/data_api/settings`}
|
||||
>
|
||||
Manage access
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -398,7 +310,6 @@ export const ApiAccessToggle = ({
|
||||
isPending={isPending}
|
||||
isError={isError}
|
||||
isSchemaExposed={isSchemaExposed}
|
||||
hasNonEmptyPrivileges={!!privileges ? hasNonEmptyPrivileges : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -411,7 +322,6 @@ const SchemaExposureOptions = ({
|
||||
isPending,
|
||||
isError,
|
||||
isSchemaExposed,
|
||||
hasNonEmptyPrivileges,
|
||||
}: {
|
||||
projectRef?: string
|
||||
schemaName?: string
|
||||
@@ -419,7 +329,6 @@ const SchemaExposureOptions = ({
|
||||
isPending: boolean
|
||||
isError: boolean
|
||||
isSchemaExposed?: boolean
|
||||
hasNonEmptyPrivileges?: boolean
|
||||
}): ReactNode => {
|
||||
const { selectedDatabaseId } = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
@@ -459,7 +368,7 @@ const SchemaExposureOptions = ({
|
||||
<Admonition type="warning" title="An error occurred while fetching Data API settings." />
|
||||
)}
|
||||
|
||||
{isSchemaExposed && apiUrl && hasNonEmptyPrivileges && (
|
||||
{isSchemaExposed && apiUrl && (
|
||||
<Input
|
||||
copy
|
||||
readOnly
|
||||
|
||||
+1
@@ -560,6 +560,7 @@ export const TableEditor = ({
|
||||
tableName={
|
||||
isNewRecord || isDuplicating ? tableFields.name : tableFields.name || table?.name
|
||||
}
|
||||
isNewRecord={isNewRecord || isDuplicating}
|
||||
handler={apiAccessToggleHandler}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useSchemasQuery } from 'data/database/schemas-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import { Check, ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
CommandSeparator_Shadcn_,
|
||||
Command_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
} from 'ui'
|
||||
@@ -29,7 +28,8 @@ interface SchemaSelectorProps {
|
||||
disabled?: boolean
|
||||
size?: 'tiny' | 'small'
|
||||
showError?: boolean
|
||||
selectedSchemaName: string
|
||||
selectedSchemaName?: string
|
||||
placeholderLabel?: string
|
||||
supportSelectAll?: boolean
|
||||
excludedSchemas?: string[]
|
||||
onSelectSchema: (name: string) => void
|
||||
@@ -43,6 +43,7 @@ export const SchemaSelector = ({
|
||||
size = 'tiny',
|
||||
showError = true,
|
||||
selectedSchemaName,
|
||||
placeholderLabel = 'Choose a schema...',
|
||||
supportSelectAll = false,
|
||||
excludedSchemas = [],
|
||||
onSelectSchema,
|
||||
@@ -122,7 +123,7 @@ export const SchemaSelector = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full flex gap-1">
|
||||
<p className="text-foreground-lighter">Choose a schema...</p>
|
||||
<p className="text-foreground-lighter">{placeholderLabel}</p>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
@@ -158,7 +159,7 @@ export const SchemaSelector = ({
|
||||
)}
|
||||
</CommandItem_Shadcn_>
|
||||
)}
|
||||
{schemas?.map((schema) => (
|
||||
{schemas.map((schema) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={schema.id}
|
||||
className="cursor-pointer flex items-center justify-between space-x-2 w-full"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { privilegeKeys } from './keys'
|
||||
import { getExposedTableCountsSql } from './privileges.sql'
|
||||
|
||||
export type ExposedTableCountsVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
selectedSchemas: string[]
|
||||
}
|
||||
|
||||
export type ExposedTableCountsResponse = {
|
||||
total_count: number
|
||||
grants_count: number
|
||||
}
|
||||
|
||||
export async function getExposedTableCounts(
|
||||
{ projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<ExposedTableCountsResponse> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!selectedSchemas) throw new Error('selectedSchemas is required')
|
||||
|
||||
const sql = getExposedTableCountsSql({ selectedSchemas })
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['exposed-table-counts', selectedSchemas],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0] as ExposedTableCountsResponse
|
||||
}
|
||||
|
||||
export type ExposedTableCountsData = Awaited<ReturnType<typeof getExposedTableCounts>>
|
||||
export type ExposedTableCountsError = ResponseError
|
||||
|
||||
export const exposedTableCountsQueryOptions = (
|
||||
{ projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
return queryOptions({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
|
||||
queryKey: privilegeKeys.exposedTableCounts(projectRef, selectedSchemas),
|
||||
queryFn: ({ signal }) =>
|
||||
getExposedTableCounts(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
selectedSchemas,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { infiniteQueryOptions } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
import { privilegeKeys } from './keys'
|
||||
import { getExposedTablesSql } from './privileges.sql'
|
||||
|
||||
export const EXPOSED_TABLES_PAGE_LIMIT = 50
|
||||
|
||||
export type ExposedTablesVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
search?: string
|
||||
}
|
||||
|
||||
export type ExposedTable = {
|
||||
id: number
|
||||
schema: string
|
||||
name: string
|
||||
status: 'granted' | 'revoked' | 'custom'
|
||||
}
|
||||
|
||||
export type ExposedTablesResponse = {
|
||||
total_count: number
|
||||
tables: ExposedTable[]
|
||||
}
|
||||
|
||||
export async function getExposedTables(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
search,
|
||||
page = 0,
|
||||
limit = EXPOSED_TABLES_PAGE_LIMIT,
|
||||
}: ExposedTablesVariables & { page?: number; limit?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<ExposedTablesResponse> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const offset = page * limit
|
||||
|
||||
const sql = getExposedTablesSql({ search, offset, limit })
|
||||
|
||||
const { result } = await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql,
|
||||
queryKey: ['exposed-tables', page],
|
||||
},
|
||||
signal
|
||||
)
|
||||
|
||||
return result[0] as ExposedTablesResponse
|
||||
}
|
||||
|
||||
export type ExposedTablesData = Awaited<ReturnType<typeof getExposedTables>>
|
||||
export type ExposedTablesError = ResponseError
|
||||
|
||||
export const exposedTablesInfiniteQueryOptions = (
|
||||
{ projectRef, connectionString, search }: ExposedTablesVariables,
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
return infiniteQueryOptions({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query
|
||||
queryKey: privilegeKeys.exposedTablesInfinite(projectRef, search),
|
||||
queryFn: ({ signal, pageParam }) =>
|
||||
getExposedTables(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
search,
|
||||
page: pageParam,
|
||||
},
|
||||
signal
|
||||
),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
initialPageParam: 0,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
const page = pages.length
|
||||
const currentTotalCount = page * EXPOSED_TABLES_PAGE_LIMIT
|
||||
const totalCount = lastPage.total_count ?? 0
|
||||
|
||||
if (currentTotalCount >= totalCount) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return page
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -3,4 +3,14 @@ export const privilegeKeys = {
|
||||
['projects', projectRef, 'database', 'table-privileges'] as const,
|
||||
columnPrivilegesList: (projectRef: string | undefined) =>
|
||||
['projects', projectRef, 'database', 'column-privileges'] as const,
|
||||
exposedTablesInfinite: (projectRef: string | undefined, search?: string) =>
|
||||
[
|
||||
'projects',
|
||||
projectRef,
|
||||
'privileges',
|
||||
'exposed-tables-infinite',
|
||||
...(search ? ([{ search }] as const) : []),
|
||||
] as const,
|
||||
exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) =>
|
||||
['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
export const IGNORED_SCHEMAS = [...INTERNAL_SCHEMAS, 'pg_catalog']
|
||||
|
||||
const IGNORED_SCHEMAS_LIST = IGNORED_SCHEMAS.map((s) => `'${s}'`).join(', ')
|
||||
|
||||
/**
|
||||
* Builds the shared `table_privileges` and `table_grants` CTEs used by
|
||||
* both the exposed-tables list query and the counts-only query.
|
||||
*
|
||||
* Returns SQL text meant to follow `WITH` (no leading `WITH` keyword).
|
||||
* Callers that append additional CTEs should add a comma after interpolation.
|
||||
*/
|
||||
function getTableGrantsCTEs({ search }: { search?: string } = {}) {
|
||||
return /* SQL */ `
|
||||
table_privileges as (
|
||||
select
|
||||
c.oid::int as id,
|
||||
n.nspname as schema_name,
|
||||
c.relname as name,
|
||||
c.relkind as kind,
|
||||
|
||||
-- Anon Privileges
|
||||
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'SELECT') as anon_select,
|
||||
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'INSERT') as anon_insert,
|
||||
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'UPDATE') as anon_update,
|
||||
bool_or(pr.rolname = 'anon' and acl.privilege_type = 'DELETE') as anon_delete,
|
||||
|
||||
-- Authenticated Privileges
|
||||
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'SELECT') as auth_select,
|
||||
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'INSERT') as auth_insert,
|
||||
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'UPDATE') as auth_update,
|
||||
bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'DELETE') as auth_delete,
|
||||
|
||||
-- Service Role Privileges
|
||||
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'SELECT') as srv_select,
|
||||
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'INSERT') as srv_insert,
|
||||
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'UPDATE') as srv_update,
|
||||
bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'DELETE') as srv_delete
|
||||
|
||||
from pg_class c
|
||||
join pg_namespace n
|
||||
on n.oid = c.relnamespace
|
||||
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl
|
||||
on true
|
||||
left join pg_roles pr
|
||||
on pr.oid = acl.grantee
|
||||
where c.relkind in ('r', 'p', 'v', 'm', 'f')
|
||||
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
||||
${search ? `and (n.nspname || '.' || c.relname) ilike '%${search}%'` : ''}
|
||||
group by c.oid, n.nspname, c.relname, c.relkind
|
||||
),
|
||||
table_grants as (
|
||||
select
|
||||
id,
|
||||
schema_name,
|
||||
name,
|
||||
kind,
|
||||
case
|
||||
-- 1. Strict Granted: All 3 roles possess ALL 4 privileges
|
||||
when (
|
||||
anon_select and anon_insert and anon_update and anon_delete and
|
||||
auth_select and auth_insert and auth_update and auth_delete and
|
||||
srv_select and srv_insert and srv_update and srv_delete
|
||||
) then 'granted'
|
||||
|
||||
-- 2. Strict Revoked: NO role possesses ANY privilege
|
||||
when not (
|
||||
anon_select or anon_insert or anon_update or anon_delete or
|
||||
auth_select or auth_insert or auth_update or auth_delete or
|
||||
srv_select or srv_insert or srv_update or srv_delete
|
||||
) then 'revoked'
|
||||
|
||||
-- 3. Custom: Anything in between
|
||||
else 'custom'
|
||||
end as status
|
||||
from table_privileges
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedTablesSql({
|
||||
search,
|
||||
offset,
|
||||
limit,
|
||||
}: {
|
||||
search?: string
|
||||
offset: number
|
||||
limit: number
|
||||
}) {
|
||||
return /* SQL */ `
|
||||
with ${getTableGrantsCTEs({ search })}
|
||||
select
|
||||
(select count(*)::int from table_grants) as total_count,
|
||||
coalesce(
|
||||
(
|
||||
select jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', tg.id,
|
||||
'schema', tg.schema_name,
|
||||
'name', tg.name,
|
||||
'status', tg.status
|
||||
)
|
||||
)
|
||||
from (
|
||||
select *
|
||||
from table_grants
|
||||
order by schema_name, name
|
||||
offset ${offset}
|
||||
limit ${limit}
|
||||
) tg
|
||||
),
|
||||
'[]'::jsonb
|
||||
) as tables;
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) {
|
||||
const schemasList =
|
||||
selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''"
|
||||
|
||||
return /* SQL */ `
|
||||
with ${getTableGrantsCTEs()}
|
||||
select
|
||||
count(*)::int as total_count,
|
||||
(count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count
|
||||
from table_grants
|
||||
`
|
||||
}
|
||||
|
||||
export function getExposedSchemasSql() {
|
||||
return /* SQL */ `
|
||||
select coalesce(
|
||||
(
|
||||
select jsonb_agg(distinct schema_name order by schema_name)
|
||||
from (
|
||||
select n.nspname as schema_name
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true
|
||||
where c.relkind in ('r', 'p', 'v', 'm', 'f')
|
||||
and n.nspname not in (${IGNORED_SCHEMAS_LIST})
|
||||
group by c.oid, n.nspname
|
||||
having
|
||||
bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'anon'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
)
|
||||
and bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
)
|
||||
and bool_or(
|
||||
pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role'
|
||||
and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
|
||||
)
|
||||
) t
|
||||
),
|
||||
'[]'::jsonb
|
||||
) as schemas;
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { UseCustomMutationOptions } from 'types'
|
||||
|
||||
import type { ConnectionVars } from '../common.types'
|
||||
import { getExposedSchemasSql } from './privileges.sql'
|
||||
|
||||
export type UpdateExposedTablesVariables = ConnectionVars & {
|
||||
tableIdsToAdd: number[]
|
||||
tableIdsToRemove: number[]
|
||||
}
|
||||
|
||||
const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => {
|
||||
if (oids.length === 0) return ''
|
||||
|
||||
const privilegeClause =
|
||||
action === 'grant'
|
||||
? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role'
|
||||
: 'revoke all on table %I.%I from anon, authenticated, service_role'
|
||||
|
||||
return /* SQL */ `
|
||||
do $$
|
||||
declare
|
||||
relname name;
|
||||
nspname name;
|
||||
begin
|
||||
for nspname, relname in
|
||||
select n.nspname, c.relname
|
||||
from pg_class c
|
||||
join pg_namespace n on n.oid = c.relnamespace
|
||||
where c.oid in (${oids.join(', ')})
|
||||
loop
|
||||
execute format('${privilegeClause}', relname, nspname);
|
||||
end loop;
|
||||
end $$;
|
||||
`
|
||||
}
|
||||
|
||||
export async function updateExposedTables({
|
||||
projectRef,
|
||||
connectionString,
|
||||
tableIdsToAdd,
|
||||
tableIdsToRemove,
|
||||
}: UpdateExposedTablesVariables): Promise<string[]> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const sqlParts: string[] = []
|
||||
|
||||
if (tableIdsToAdd.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant'))
|
||||
}
|
||||
|
||||
if (tableIdsToRemove.length > 0) {
|
||||
sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke'))
|
||||
}
|
||||
|
||||
sqlParts.push(getExposedSchemasSql())
|
||||
|
||||
const { result } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: sqlParts.join('\n'),
|
||||
queryKey: ['update-exposed-tables'],
|
||||
})
|
||||
|
||||
return (result[0] as { schemas: string[] }).schemas
|
||||
}
|
||||
|
||||
type UpdateExposedTablesData = Awaited<ReturnType<typeof updateExposedTables>>
|
||||
|
||||
export const useUpdateExposedTablesMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<
|
||||
UseCustomMutationOptions<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>,
|
||||
'mutationFn'
|
||||
> = {}) => {
|
||||
return useMutation<UpdateExposedTablesData, Error, UpdateExposedTablesVariables>({
|
||||
mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars),
|
||||
onError(error: Error) {
|
||||
toast.error(`Failed to update table access: ${error.message}`)
|
||||
},
|
||||
...(onError ? { onError } : {}),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -58,11 +58,10 @@ async function verifyTablePrivileges(
|
||||
|
||||
/**
|
||||
* Locates the API access toggle switch for Data API Access.
|
||||
* The switch is labeled by the nearby "Data API Access" text.
|
||||
* Only present when creating or duplicating a table (not when editing).
|
||||
*/
|
||||
function getApiAccessToggle(page: Page) {
|
||||
function getApiAccessSwitch(page: Page) {
|
||||
const sidePanel = page.getByTestId('table-editor-side-panel')
|
||||
// The switch is near the "Data API Access" label - get the section first, then find the switch
|
||||
const dataApiSection = sidePanel
|
||||
.locator('div')
|
||||
.filter({ hasText: 'Data API Access' })
|
||||
@@ -71,23 +70,12 @@ function getApiAccessToggle(page: Page) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the settings button for granular privilege settings.
|
||||
* Locates the "Manage access" link shown when editing an existing table.
|
||||
* Links out to the API settings page.
|
||||
*/
|
||||
function getPrivilegeSettingsButton(page: Page) {
|
||||
function getManageAccessLink(page: Page) {
|
||||
const sidePanel = page.getByTestId('table-editor-side-panel')
|
||||
return sidePanel.getByRole('button', { name: 'Configure API privileges' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the privilege selector combobox for a specific role in the privileges popover.
|
||||
* The popover must already be open.
|
||||
*/
|
||||
function getRolePrivilegeSelector(page: Page, roleLabel: 'Anonymous (anon)' | 'Authenticated') {
|
||||
// The popover is a dialog with structure: paragraph (role label) followed by combobox
|
||||
// We find the paragraph with the role text, then get the adjacent combobox
|
||||
const popoverContent = page.locator('[data-radix-popper-content-wrapper]')
|
||||
// Get the paragraph containing the role label, then navigate to the sibling combobox
|
||||
return popoverContent.getByText(roleLabel, { exact: true }).locator('..').getByRole('combobox')
|
||||
return sidePanel.getByRole('link', { name: 'Manage access' })
|
||||
}
|
||||
|
||||
test.describe('API Access Toggle', () => {
|
||||
@@ -107,8 +95,8 @@ test.describe('API Access Toggle', () => {
|
||||
// Fill in table name
|
||||
await page.getByTestId('table-name-input').fill(tableName)
|
||||
|
||||
// Find and click the API access toggle to turn it off
|
||||
const toggle = getApiAccessToggle(page)
|
||||
// Verify the toggle is checked by default
|
||||
const toggle = getApiAccessSwitch(page)
|
||||
await expect(toggle).toBeChecked()
|
||||
|
||||
// Create the table
|
||||
@@ -155,8 +143,8 @@ test.describe('API Access Toggle', () => {
|
||||
// Fill in table name
|
||||
await page.getByTestId('table-name-input').fill(tableName)
|
||||
|
||||
// Find and click the API access toggle to turn it off
|
||||
const toggle = getApiAccessToggle(page)
|
||||
// Toggle API access off
|
||||
const toggle = getApiAccessSwitch(page)
|
||||
await expect(toggle).toBeChecked()
|
||||
await toggle.click()
|
||||
await expect(toggle, 'Toggle should be unchecked after clicking').not.toBeChecked()
|
||||
@@ -195,7 +183,7 @@ test.describe('API Access Toggle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('shows API access toggle when editing an existing table', async ({ page, ref }) => {
|
||||
test('shows Manage access link when editing an existing table', async ({ page, ref }) => {
|
||||
const tableName = `${TABLE_NAME_PREFIX}_edit`
|
||||
|
||||
// Create a table first
|
||||
@@ -207,138 +195,40 @@ test.describe('API Access Toggle', () => {
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await createPromise
|
||||
|
||||
// Wait for success toast which indicates all operations are complete
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should appear after table creation'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
// Dismiss toast to prevent it from blocking subsequent interactions
|
||||
await dismissToastsIfAny(page)
|
||||
|
||||
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
|
||||
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
|
||||
|
||||
// Verify table was created
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
|
||||
'Table should be visible after creation'
|
||||
).toBeVisible()
|
||||
|
||||
// Verify default full privileges were granted
|
||||
await verifyTablePrivileges('public', tableName, {
|
||||
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
})
|
||||
|
||||
// Navigate back to table editor
|
||||
let loadPromise = waitForTableToLoad(page, ref)
|
||||
// Navigate back and open the edit panel
|
||||
const loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
|
||||
// Click on the table to view it
|
||||
const navigationPromise = page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await navigationPromise
|
||||
|
||||
// Open edit table dialog via context menu
|
||||
await openTableContextMenu(page, tableName)
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
|
||||
// Verify the side panel is open
|
||||
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
|
||||
|
||||
// Verify Data API Access section is visible
|
||||
// Data API Access section is visible
|
||||
await expect(
|
||||
page.getByText('Data API Access'),
|
||||
'Data API Access label should be visible in edit mode'
|
||||
).toBeVisible()
|
||||
|
||||
// Verify the toggle is present
|
||||
const toggle = getApiAccessToggle(page)
|
||||
await expect(toggle, 'API Access toggle should be visible in edit mode').toBeVisible()
|
||||
})
|
||||
|
||||
test('creates table with partial privileges and verifies correct grants', async ({
|
||||
page,
|
||||
ref,
|
||||
}) => {
|
||||
const tableName = `${TABLE_NAME_PREFIX}_partial_grants`
|
||||
|
||||
// Open new table dialog
|
||||
await page.getByRole('button', { name: 'New table', exact: true }).click()
|
||||
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
|
||||
|
||||
// Fill in table name
|
||||
await page.getByTestId('table-name-input').fill(tableName)
|
||||
|
||||
// Open the privilege settings popover
|
||||
const settingsButton = getPrivilegeSettingsButton(page)
|
||||
await settingsButton.click()
|
||||
|
||||
await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
|
||||
|
||||
// Modify anon privileges - leave only SELECT
|
||||
const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
|
||||
await anonSelector.click()
|
||||
|
||||
// Click DELETE to toggle it off
|
||||
await page.getByRole('option', { name: 'DELETE' }).click()
|
||||
// Click UPDATE to toggle it off
|
||||
await page.getByRole('option', { name: 'UPDATE' }).click()
|
||||
await page.getByRole('option', { name: 'INSERT' }).click()
|
||||
|
||||
// Close the dropdown by clicking the combobox again
|
||||
await anonSelector.click()
|
||||
|
||||
// Wait for dropdown to close
|
||||
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Modify authenticated privileges - remove DELETE and UPDATE (leave SELECT + INSERT)
|
||||
const authSelector = getRolePrivilegeSelector(page, 'Authenticated')
|
||||
await authSelector.click()
|
||||
|
||||
// Remove all except SELECT
|
||||
await page.getByRole('option', { name: 'DELETE' }).click()
|
||||
await page.getByRole('option', { name: 'UPDATE' }).click()
|
||||
|
||||
// Close the dropdown by clicking the combobox again
|
||||
await authSelector.click()
|
||||
|
||||
// Wait for dropdown to close
|
||||
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Close the popover by pressing Escape
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// Create the table
|
||||
const createTablePromise = createApiResponseWaiter(
|
||||
page,
|
||||
'pg-meta',
|
||||
ref,
|
||||
'query?key=table-create'
|
||||
)
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await createTablePromise
|
||||
|
||||
// Wait for success toast which indicates all operations (including privilege updates) are complete
|
||||
// In edit mode the panel shows a "Manage access" link instead of a toggle switch
|
||||
const manageAccessLink = getManageAccessLink(page)
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should appear after table creation'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
|
||||
|
||||
// Verify table was created
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
|
||||
'Table should be visible after creation'
|
||||
manageAccessLink,
|
||||
'Manage access link should be visible in edit mode'
|
||||
).toBeVisible()
|
||||
|
||||
// Verify partial grants - anon: SELECT; authenticated: SELECT, INSERT
|
||||
await verifyTablePrivileges('public', tableName, {
|
||||
anon: ['SELECT'],
|
||||
authenticated: ['SELECT', 'INSERT'],
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves API grants when editing non-privilege table properties', async ({
|
||||
@@ -347,78 +237,54 @@ test.describe('API Access Toggle', () => {
|
||||
}) => {
|
||||
const tableName = `${TABLE_NAME_PREFIX}_preserve_grants`
|
||||
|
||||
// Step 1: Create a table with partial privileges (only SELECT and INSERT for anon)
|
||||
// Step 1: Create a table with API access on (default — full grants)
|
||||
await page.getByRole('button', { name: 'New table', exact: true }).click()
|
||||
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
|
||||
|
||||
await page.getByTestId('table-name-input').fill(tableName)
|
||||
|
||||
// Open privilege settings and set partial privileges
|
||||
const settingsButton = getPrivilegeSettingsButton(page)
|
||||
await settingsButton.click()
|
||||
await expect(page.getByText('Adjust API privileges per role')).toBeVisible()
|
||||
// Verify toggle is on by default
|
||||
const toggle = getApiAccessSwitch(page)
|
||||
await expect(toggle).toBeChecked()
|
||||
|
||||
// Modify anon privileges - keep only SELECT and INSERT
|
||||
const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)')
|
||||
await anonSelector.click()
|
||||
await page.getByRole('option', { name: 'DELETE' }).click()
|
||||
await page.getByRole('option', { name: 'UPDATE' }).click()
|
||||
await anonSelector.click()
|
||||
await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Keep authenticated with full privileges
|
||||
await page.keyboard.press('Escape') // Close popover
|
||||
|
||||
// Create the table
|
||||
let createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await createPromise
|
||||
|
||||
// Wait for success toast which indicates all operations (including privilege updates) are complete
|
||||
await expect(
|
||||
page.getByText(`Table ${tableName} is good to go!`),
|
||||
'Success toast should appear after table creation'
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
|
||||
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
|
||||
await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${tableName}`, exact: true }),
|
||||
'Table should be created'
|
||||
).toBeVisible()
|
||||
|
||||
// Verify initial privileges before edit
|
||||
// Verify full privileges were granted
|
||||
await verifyTablePrivileges('public', tableName, {
|
||||
anon: ['SELECT', 'INSERT'],
|
||||
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
})
|
||||
|
||||
// Navigate back to table editor
|
||||
// Step 2: Navigate back and edit only the description
|
||||
let loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
|
||||
// Step 2: Edit the table's description (without touching privileges)
|
||||
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
||||
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
||||
|
||||
await openTableContextMenu(page, tableName)
|
||||
await page.getByRole('menuitem', { name: 'Edit table' }).click()
|
||||
|
||||
await expect(page.getByTestId('table-editor-side-panel')).toBeVisible()
|
||||
|
||||
// Add a description without modifying privileges
|
||||
const descriptionInput = page
|
||||
.getByTestId('table-editor-side-panel')
|
||||
.getByPlaceholder('Optional')
|
||||
await descriptionInput.fill('Test description for grant preservation')
|
||||
|
||||
// Save the changes
|
||||
const updatePromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-update')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await updatePromise
|
||||
|
||||
// Wait for success toast which indicates all operations are complete
|
||||
await expect(
|
||||
page.getByText(`Successfully updated ${tableName}!`),
|
||||
'Success toast should appear after table update'
|
||||
@@ -426,18 +292,17 @@ test.describe('API Access Toggle', () => {
|
||||
|
||||
await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' })
|
||||
|
||||
// Step 3: Verify the privileges remain unchanged after edit
|
||||
// Step 3: Verify the full privileges are unchanged after the description edit
|
||||
await verifyTablePrivileges('public', tableName, {
|
||||
anon: ['SELECT', 'INSERT'],
|
||||
anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'],
|
||||
})
|
||||
|
||||
// Navigate back to table editor for cleanup
|
||||
// Clean up
|
||||
loadPromise = waitForTableToLoad(page, ref)
|
||||
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
||||
await loadPromise
|
||||
|
||||
// Clean up
|
||||
await deleteTable(page, ref, tableName)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user