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, internalSchemasCannotExpose } 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 .union([ z.literal(''), z.coerce.number().int().min(1).max(1000000, "Can't be more than 1,000,000"), ]) .refine((value) => value !== '', 'Max rows is required') .transform((value) => Number(value)), dbPool: z.coerce .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>({ resolver: zodResolver(formSchema), mode: 'onChange', defaultValues, }) const resetForm = useCallback(() => { form.reset({ ...defaultValues }) }, [form, defaultValues]) const onSubmit = async (values: z.infer) => { 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] ) const protectedSchemasExposed = useMemo( () => watchedDbSchema.filter((schema) => internalSchemasCannotExpose.has(schema)), [watchedDbSchema] ) return (
{isLoading ? ( ) : isError ? ( ) : ( <> { 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, }) } }} /> {protectedSchemasExposed.length > 0 ? (

{protectedSchemasExposed.length} protected schema {protectedSchemasExposed.length > 1 ? 's' : ''} is currently exposed and should be removed

) : missingExposedSchema.length > 0 ? (

{missingExposedSchema.length} exposed schema {missingExposedSchema.length > 1 ? 's' : ''} does not exist — safe to remove

) : null}
{ 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, }) } }} /> { 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, }) } }} /> {watchedDbSchema.includes('public') && ( (
)} /> )} {watchedDbSchema.length === 0 && ( )}
( {isLoadingSchemas ? (
) : ( {allSchemas.length <= 0 ? ( no ) : ( allSchemas.map((x) => ( {x.name} )) )} )}
)} />
( rows )} /> ( field.onChange( e.target.value === '' ? null : Number(e.target.value) ) } value={field.value === null ? '' : field.value} /> connections )} /> )}
{IS_PLATFORM && ( )}
{IS_PLATFORM && (
)}
{IS_PLATFORM && setShowModal(false)} />}
) }