Files
supabase/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
T
Gildas Garcia 0283d92505 fix: allow users to remove missing schemas from the data API (#46169)
## Problem

Exposed schemas (from the Data API) that have been deleted manually (for
instance, using the SQL editor) are still present in the Data API
setting, crashing Postgrest clients. However, the Data API setting does
not show them anymore preventing users from fixing the issue.

## Solution

- Detect missing schemas and show users an error message
- Make missing schema visually distinct
- Allow users to unselect them and update the Data API settings

## How to test

- Create a new schema
- Verify that it is exposed (expose it if necessary) in the Data API
settings
- Delete the schema using the SQL editor
- Verify that you can unselect it from the Data API settings

## Screenshots

<img width="470" height="243" alt="image"
src="https://github.com/user-attachments/assets/2a1f7dc5-c9a9-4779-9f26-1d3e0f66fb8f"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Dropdown for exposed API schemas now shows selected schemas that no
longer exist and labels them "This schema does not exist", allowing
users to toggle them.
* "Exposed schemas" field now shows an inline validation/error message
when selected schemas are unavailable to help resolve configuration
issues.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46169?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 17:33:03 +02:00

593 lines
24 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import { Lock } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
Form,
FormControl,
FormField,
FormInputGroupInput,
FormItem,
InputGroup,
InputGroupAddon,
InputGroupText,
Skeleton,
Switch,
useWatch,
} from 'ui'
import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
MultiSelector,
MultiSelectorContent,
MultiSelectorItem,
MultiSelectorList,
MultiSelectorTrigger,
} from 'ui-patterns/multi-select'
import { z } from 'zod'
import { ExposedSchemaSelector } from './ExposedSchemaSelector'
import { HardenAPIModal } from './HardenAPIModal'
import { ExposedFunctionSelector } from '@/components/interfaces/Settings/API/ExposedFunctionSelector'
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 { useSchemasQuery } from '@/data/database/schemas-query'
import { defaultPrivilegesQueryOptions } from '@/data/privileges/default-privileges-query'
import { privilegeKeys } from '@/data/privileges/keys'
import { useUpdateDefaultPrivilegesMutation } from '@/data/privileges/update-default-privileges-mutation'
import { useUpdateExposedEntitiesMutation } from '@/data/privileges/update-exposed-entities-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import useLatest from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
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"),
dbPool: z
.number()
.min(0, 'Must be more than 0')
.max(1000, "Can't be more than 1000")
.optional()
.nullable(),
// Default privileges toggle
defaultPrivilegesGranted: z.boolean(),
// Fields for expose toggles
tableIdsToAdd: z.array(z.number()),
tableIdsToRemove: z.array(z.number()),
functionNamesToAdd: z.array(z.string()),
functionNamesToRemove: z.array(z.string()),
})
export const PostgrestConfig = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const {
data: config,
isError,
isPending: isLoadingConfig,
isSuccess: isSuccessConfig,
} = useProjectPostgrestConfigQuery({ projectRef })
const {
data: allSchemas = [],
isPending: isLoadingSchemas,
isSuccess: isSuccessSchemas,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: defaultPrivilegesGranted,
isPending: isLoadingDefaultPrivileges,
isSuccess: isSuccessDefaultPrivileges,
} = useQuery(
defaultPrivilegesQueryOptions({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
)
const configDbSchemas = useMemo(
() => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []),
[config?.db_schema]
)
const isLoading = isLoadingConfig || isLoadingSchemas || isLoadingDefaultPrivileges
const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
onError: noop,
})
const { mutateAsync: updateExposedEntities } = useUpdateExposedEntitiesMutation({ onError: noop })
const { mutateAsync: updateDefaultPrivileges } = useUpdateDefaultPrivilegesMutation({
onError: noop,
})
const [isUpdating, setIsUpdating] = useState(false)
const formId = 'project-postgres-config'
const { can: canUpdatePostgrestConfigPermission, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest')
const canUpdatePostgrestConfig = IS_PLATFORM && canUpdatePostgrestConfigPermission
const defaultValues = useMemo(() => {
return {
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(Boolean),
dbPool: config?.db_pool,
defaultPrivilegesGranted: defaultPrivilegesGranted ?? true,
tableIdsToAdd: [] as number[],
tableIdsToRemove: [] as number[],
functionNamesToAdd: [] as string[],
functionNamesToRemove: [] as string[],
}
}, [config, configDbSchemas, defaultPrivilegesGranted])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues,
})
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')
setIsUpdating(true)
try {
let dbSchema = values.dbSchema.join(',')
await updateExposedEntities({
projectRef,
connectionString: project?.connectionString,
tableIdsToAdd: values.tableIdsToAdd,
tableIdsToRemove: values.tableIdsToRemove,
functionNamesToAdd: values.functionNamesToAdd,
functionNamesToRemove: values.functionNamesToRemove,
})
if (values.defaultPrivilegesGranted !== defaultPrivilegesGranted) {
await updateDefaultPrivileges({
projectRef,
connectionString: project?.connectionString,
granted: values.defaultPrivilegesGranted,
})
}
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),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedFunctionsInfinite(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.exposedFunctionCounts(projectRef),
}),
queryClient.invalidateQueries({
queryKey: privilegeKeys.defaultPrivileges(projectRef),
}),
])
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,
defaultPrivilegesGranted: values.defaultPrivilegesGranted,
tableIdsToAdd: [],
tableIdsToRemove: [],
functionNamesToAdd: [],
functionNamesToRemove: [],
})
} 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 && isSuccessDefaultPrivileges
useEffect(() => {
if (isReady) {
resetFormRef.current()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady])
const watchedDbSchema = useWatch({ control: form.control, name: 'dbSchema' })
const watchedTableIdsToAdd = useWatch({ control: form.control, name: 'tableIdsToAdd' })
const watchedTableIdsToRemove = useWatch({
control: form.control,
name: 'tableIdsToRemove',
})
const watchedFunctionNamesToAdd = useWatch({
control: form.control,
name: 'functionNamesToAdd',
})
const watchedFunctionNamesToRemove = useWatch({
control: form.control,
name: 'functionNamesToRemove',
})
const missingExposedSchema = useMemo(
() => watchedDbSchema.filter((schema) => !allSchemas.some((s) => s.name === schema)),
[allSchemas, watchedDbSchema]
)
return (
<PageSection id="postgrest-config" className="first:pt-0">
<PageSectionContent>
<Card className="mb-4">
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
{isLoading ? (
<CardContent>
<GenericSkeletonLoader />
</CardContent>
) : isError ? (
<CardContent>
<Admonition type="destructive" description="Failed to retrieve API settings." />
</CardContent>
) : (
<>
<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."
error={
missingExposedSchema.length > 0 ? 'Some exposed schemas are missing' : null
}
>
<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>
<FormItemLayout
isReactForm={false}
layout="flex-row-reverse"
label="Exposed functions"
description="Toggle Data API access for individual functions."
>
<ExposedFunctionSelector
selectedSchemas={watchedDbSchema}
pendingAddFunctionNames={watchedFunctionNamesToAdd}
pendingRemoveFunctionNames={watchedFunctionNamesToRemove}
onTogglePendingAdd={(functionName) => {
const current = form.getValues('functionNamesToAdd')
if (current.includes(functionName)) {
form.setValue(
'functionNamesToAdd',
current.filter((x) => x !== functionName),
{ shouldDirty: true }
)
} else {
form.setValue('functionNamesToAdd', [...current, functionName], {
shouldDirty: true,
})
}
}}
onTogglePendingRemove={(functionName) => {
const current = form.getValues('functionNamesToRemove')
if (current.includes(functionName)) {
form.setValue(
'functionNamesToRemove',
current.filter((x) => x !== functionName),
{ shouldDirty: true }
)
} else {
form.setValue('functionNamesToRemove', [...current, functionName], {
shouldDirty: true,
})
}
}}
/>
</FormItemLayout>
{watchedDbSchema.includes('public') && (
<FormField
control={form.control}
name="defaultPrivilegesGranted"
render={({ field }) => (
<FormItem>
<FormItemLayout
layout="flex-row-reverse"
label="Automatically expose new tables"
description="Grants privileges to Data API roles by default, exposing new tables. We recommend disabling this to control access manually."
>
<FormControl>
<div>
<Switch
size="large"
disabled={!canUpdatePostgrestConfig}
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
</FormControl>
</FormItemLayout>
</FormItem>
)}
/>
)}
{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>
<FormField
control={form.control}
name="dbExtraSearchPath"
render={({ field }) => (
<FormItem>
<FormItemLayout
layout="flex-row-reverse"
label="Extra search path"
description="Extra schemas to add to the search path of every request."
>
{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>
{allSchemas.length <= 0 ? (
<MultiSelectorItem key="empty" value="no">
no
</MultiSelectorItem>
) : (
allSchemas.map((x) => (
<MultiSelectorItem key={x.id + '-' + x.name} value={x.name}>
{x.name}
</MultiSelectorItem>
))
)}
</MultiSelectorList>
</MultiSelectorContent>
</MultiSelector>
)}
</FormItemLayout>
</FormItem>
)}
/>
</CardContent>
<CardContent>
<FormField
control={form.control}
name="maxRows"
render={({ field }) => (
<FormItem>
<FormItemLayout
layout="flex-row-reverse"
label="Max rows"
description="The maximum number of rows returned from a view, table, or function. Limits payload size for accidental or malicious requests."
>
<FormControl>
<InputGroup>
<FormInputGroupInput
size="small"
disabled={!canUpdatePostgrestConfig}
{...field}
type="number"
onChange={(e) => field.onChange(Number(e.target.value))}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>rows</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
</FormItem>
)}
/>
</CardContent>
<CardContent>
<FormField
control={form.control}
name="dbPool"
render={({ field }) => (
<FormItem>
<FormItemLayout
layout="flex-row-reverse"
label="Pool size"
description="Number of maximum connections to keep open in the Data API server's database pool. Unset to let it be configured automatically based on compute size."
>
<FormControl>
<InputGroup>
<FormInputGroupInput
size="small"
disabled={!canUpdatePostgrestConfig}
{...field}
type="number"
placeholder="Configured automatically"
onChange={(e) =>
field.onChange(
e.target.value === '' ? null : Number(e.target.value)
)
}
value={field.value === null ? '' : field.value}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>connections</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormControl>
</FormItemLayout>
</FormItem>
)}
/>
</CardContent>
</>
)}
</form>
</Form>
{IS_PLATFORM && (
<CardFooter className="border-t">
<FormActions
form={formId}
isSubmitting={isUpdating}
hasChanges={form.formState.isDirty}
handleReset={resetForm}
disabled={!canUpdatePostgrestConfig}
helper={
isPermissionsLoaded && !canUpdatePostgrestConfigPermission
? "You need additional permissions to update your project's API settings"
: undefined
}
/>
</CardFooter>
)}
</Card>
{IS_PLATFORM && (
<Card className="mb-4">
<CardContent>
<FormItemLayout
isReactForm={false}
layout="flex-row-reverse"
label="Harden Data API"
description="Expose a custom schema instead of the public schema"
>
<div className="flex gap-2 items-center justify-end">
<Button type="default" icon={<Lock />} onClick={() => setShowModal(true)}>
Harden Data API
</Button>
</div>
</FormItemLayout>
</CardContent>
</Card>
)}
</PageSectionContent>
{IS_PLATFORM && <HardenAPIModal visible={showModal} onClose={() => setShowModal(false)} />}
</PageSection>
)
}