| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592 |
- 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 as any),
- 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>
- )
- }
|