diff --git a/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx new file mode 100644 index 0000000000000..f24c7fb34b6f6 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/DataApi/OverviewTab.tsx @@ -0,0 +1,40 @@ +import { useParams } from 'common' +import { AlertCircle } from 'lucide-react' +import { Alert_Shadcn_, AlertTitle_Shadcn_, cn } from 'ui' + +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { DataApiEnableSwitch } from '@/components/interfaces/Settings/API/DataApiEnableSwitch' +import { DataApiProjectUrlCard } from '@/components/interfaces/Settings/API/DataApiProjectUrlCard' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from '@/lib/constants' + +export const DataApiOverviewTab = () => { + const { ref: projectRef } = useParams() + const { data: project, isPending: isProjectLoading } = useSelectedProjectQuery() + const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) + + const isLoading = isProjectLoading || isConfigLoading + + return ( + +
+ {!isProjectLoading && project?.status !== PROJECT_STATUS.ACTIVE_HEALTHY ? ( + + + + API settings are unavailable as the project is not active + + + ) : ( + <> +
+ +
+ + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx new file mode 100644 index 0000000000000..819323890c765 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/DataApi/SettingsTab.tsx @@ -0,0 +1,40 @@ +import { useParams } from 'common' +import { AlertCircle } from 'lucide-react' +import Link from 'next/link' +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui' +import { PageContainer } from 'ui-patterns' + +import { ServiceList } from '@/components/interfaces/Settings/API/ServiceList' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' + +export const DataApiSettingsTab = () => { + const { ref: projectRef } = useParams() + const { isEnabled, isPending } = useIsDataApiEnabled({ projectRef }) + + if (!isPending && !isEnabled) { + return ( +
+ + + Data API is disabled + + Enable the Data API in the{' '} + + Overview + {' '} + tab to configure settings. + + +
+ ) + } + + return ( + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index 8dff7c8b05973..c9ad5e65f21a9 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -1,4 +1,4 @@ -import { Clock5, Layers, Timer, Vault, Webhook, Receipt } from 'lucide-react' +import { Clock5, Code2, Layers, Timer, Vault, Webhook } from 'lucide-react' import dynamic from 'next/dynamic' import Image from 'next/image' import { ComponentType, ReactNode } from 'react' @@ -260,6 +260,53 @@ const SUPABASE_INTEGRATIONS: IntegrationDefinition[] = [ return null }, }, + { + id: 'data_api', + type: 'custom' as const, + requiredExtensions: [], + name: `Data API`, + icon: ({ className, ...props } = {}) => ( + + ), + description: 'Auto-generate an API directly from your database schema', + docsUrl: `${DOCS_URL}/guides/api`, + author: authorSupabase, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'settings', + label: 'Settings', + }, + ], + navigate: (_id: string, pageId: string = 'overview', _childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/DataApi/OverviewTab').then( + (mod) => mod.DataApiOverviewTab + ), + { + loading: Loading, + } + ) + case 'settings': + return dynamic( + () => + import('components/interfaces/Integrations/DataApi/SettingsTab').then( + (mod) => mod.DataApiSettingsTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, { id: 'graphiql', type: 'postgres_extension' as const, diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 14e92904770d9..8b9d954af0aae 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -6,6 +6,7 @@ import { useFDWsQuery } from 'data/fdw/fdws-query' import { useFlag } from 'common' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { IS_PLATFORM } from 'lib/constants' import { EMPTY_ARR } from 'lib/void' import { INSTALLATION_INSTALLED_SUFFIX, @@ -30,6 +31,9 @@ export const useInstalledIntegrations = () => { if (!stripeSyncEnabled && integration.id === 'stripe_sync_engine') { return false } + if (!IS_PLATFORM && integration.id === 'data_api') { + return false + } return true }) }, [integrationsWrappers, stripeSyncEnabled]) @@ -76,6 +80,9 @@ export const useInstalledIntegrations = () => { if (integration.id === 'webhooks') { return isHooksEnabled } + if (integration.id === 'data_api') { + return true + } if (integration.id === 'stripe_sync_engine') { const stripeSchema = schemas?.find(({ name }) => name === 'stripe') return ( diff --git a/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx b/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx new file mode 100644 index 0000000000000..e17deb9f1681e --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiDisabledAlert.tsx @@ -0,0 +1,19 @@ +import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, WarningIcon } from 'ui' + +export const DataApiDisabledAlert = () => { + return ( + + + No schemas can be queried + +

+ With this setting disabled, you will not be able to query any schemas via the Data API. +

+

+ You will see errors from the Postgrest endpoint{' '} + /rest/v1/. +

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx new file mode 100644 index 0000000000000..ec63e627042ff --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.tsx @@ -0,0 +1,163 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { useCallback, useEffect, useReducer } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { Card } from 'ui' + +import { DataApiEnableSwitchForm } from './DataApiEnableSwitchForm' +import { DataApiEnableSwitchError, DataApiEnableSwitchLoading } from './DataApiEnableSwitchStates' +import { dataApiFormSchema, type DataApiFormValues } from './DataApiEnableSwitch.types' +import { + enableCheckReducer, + getDefaultSchemas, + queryUnsafeEntitiesInApi, +} from './DataApiEnableSwitch.utils' +import { UnsafeEntitiesConfirmModal } from './UnsafeEntitiesConfirmModal' +import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' +import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' + +export const DataApiEnableSwitch = () => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } = + useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') + + const { + data: config, + isError, + isPending: isLoadingConfig, + } = useProjectPostgrestConfigQuery({ projectRef }) + const { isEnabled, isPending: isEnabledCheckPending } = useIsDataApiEnabled({ projectRef }) + + const { mutate: updatePostgrestConfig, isPending: isUpdating } = + useProjectPostgrestConfigUpdateMutation({ + onSuccess: (_data, variables) => { + toast.success(variables.dbSchema ? 'Data API enabled' : 'Data API disabled') + }, + }) + + const [enableCheck, dispatchEnableCheck] = useReducer(enableCheckReducer, { status: 'idle' }) + + const formId = 'data-api-enable-form' + const isLoading = isLoadingConfig || !projectRef + + const form = useForm({ + resolver: zodResolver(dataApiFormSchema), + mode: 'onChange', + defaultValues: { + enableDataApi: false, + }, + }) + + const syncForm = useStaticEffectEvent(() => { + if (!isEnabledCheckPending) { + form.reset({ enableDataApi: isEnabled }) + } + }) + useEffect(() => { + syncForm() + }, [syncForm, isEnabled]) + + const doUpdate = useCallback( + (enableDataApi: boolean) => { + if (!projectRef || !config) return + + const dbSchema = enableDataApi ? getDefaultSchemas(config.db_schema).join(', ') : '' + + updatePostgrestConfig({ + projectRef, + dbSchema, + maxRows: config.max_rows, + dbExtraSearchPath: config.db_extra_search_path ?? '', + dbPool: config.db_pool ?? null, + }) + }, + [projectRef, config, updatePostgrestConfig] + ) + + const onSubmit = useCallback( + async ({ enableDataApi }: DataApiFormValues) => { + if (!projectRef) return + + if (!enableDataApi || isEnabled) { + doUpdate(enableDataApi) + return + } + + // Enabling — check for entities with security issues in the target schemas + const targetSchemas = getDefaultSchemas(config?.db_schema) + + dispatchEnableCheck({ type: 'START_CHECK' }) + try { + const entities = await queryUnsafeEntitiesInApi({ + projectRef, + connectionString: project?.connectionString, + schemas: targetSchemas, + }) + + if (entities.length > 0) { + dispatchEnableCheck({ type: 'ENTITIES_FOUND', unsafeEntities: entities }) + } else { + dispatchEnableCheck({ type: 'DISMISS' }) + doUpdate(true) + } + } catch (error) { + console.error('Failed to check for exposed entities', error) + dispatchEnableCheck({ type: 'DISMISS' }) + toast.error('Failed to check for exposed entities') + } + }, + [projectRef, isEnabled, config?.db_schema, project?.connectionString, doUpdate] + ) + + const handleReset = useCallback(() => { + if (isEnabledCheckPending) return + form.reset({ enableDataApi: isEnabled }) + }, [isEnabledCheckPending, isEnabled, form]) + + const isBusy = isUpdating || enableCheck.status === 'checking' + const disabled = !canUpdatePostgrestConfig || isBusy + const permissionsHelper = + isPermissionsLoaded && !canUpdatePostgrestConfig + ? "You need additional permissions to update your project's API settings" + : undefined + + const cardContent = isLoading ? ( + + ) : isError || !config ? ( + + ) : ( + + ) + + return ( + <> + {cardContent} + + dispatchEnableCheck({ type: 'DISMISS' })} + onConfirm={() => { + dispatchEnableCheck({ type: 'DISMISS' }) + doUpdate(true) + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts new file mode 100644 index 0000000000000..37be4a242f898 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +import type { ExposedEntity } from './DataApiEnableSwitch.utils' + +export const dataApiFormSchema = z.object({ + enableDataApi: z.boolean(), +}) + +export type DataApiFormValues = z.infer + +export type EnableCheckState = + | { status: 'idle' } + | { status: 'checking' } + | { status: 'confirming'; unsafeEntities: Array } + +export type EnableCheckAction = + | { type: 'START_CHECK' } + | { type: 'ENTITIES_FOUND'; unsafeEntities: Array } + | { type: 'DISMISS' } diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts new file mode 100644 index 0000000000000..0e423ba255e2a --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import type { EnableCheckState } from './DataApiEnableSwitch.types' +import { enableCheckReducer, getDefaultSchemas } from './DataApiEnableSwitch.utils' + +describe('getDefaultSchemas', () => { + it('returns ["public"] for undefined', () => { + expect(getDefaultSchemas(undefined)).toEqual(['public']) + }) + + it('returns ["public"] for null', () => { + expect(getDefaultSchemas(null)).toEqual(['public']) + }) + + it('returns ["public"] for empty string', () => { + expect(getDefaultSchemas('')).toEqual(['public']) + }) + + it('returns ["public"] for whitespace-only string', () => { + expect(getDefaultSchemas(' , , ')).toEqual(['public']) + }) + + it('parses a single schema', () => { + expect(getDefaultSchemas('api')).toEqual(['api']) + }) + + it('parses multiple comma-separated schemas', () => { + expect(getDefaultSchemas('public, storage, graphql_public')).toEqual([ + 'public', + 'storage', + 'graphql_public', + ]) + }) + + it('trims whitespace from schemas', () => { + expect(getDefaultSchemas(' public , storage ')).toEqual(['public', 'storage']) + }) + + it('filters out empty segments', () => { + expect(getDefaultSchemas('public,,storage,')).toEqual(['public', 'storage']) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts new file mode 100644 index 0000000000000..0e97ada28a678 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitch.utils.ts @@ -0,0 +1,68 @@ +import { unsafeEntitiesInApiSql } from '@/data/queries/sql/tables-without-rls' +import { executeSql } from '@/data/sql/execute-sql-query' + +import type { EnableCheckAction, EnableCheckState } from './DataApiEnableSwitch.types' + +export type ExposedEntity = { + schema: string + name: string + type: 'table' | 'foreign table' | 'materialized view' | 'view' +} + +/** + * Queries for entities that would be exposed through the Data API with + * potential security issues: tables without RLS, foreign tables, materialized + * views, and views without SECURITY INVOKER. + * + * This checks against the _target_ schemas rather than the currently active + * PostgREST config, so it works correctly when enabling the Data API. + */ +export async function queryUnsafeEntitiesInApi({ + projectRef, + connectionString, + schemas, +}: { + projectRef: string + connectionString?: string | null + schemas: Array +}): Promise> { + if (schemas.length === 0) return [] + + const { result } = await executeSql>({ + projectRef, + connectionString, + sql: unsafeEntitiesInApiSql(schemas), + queryKey: ['unsafe-entities-in-api'], + }) + + return result ?? [] +} + +export const getDefaultSchemas = (dbSchema: string | null | undefined) => { + const schemas = + dbSchema + ?.split(',') + .map((schema) => schema.trim()) + .filter((schema) => schema.length > 0) ?? [] + + return schemas.length > 0 ? schemas : ['public'] +} + +export function enableCheckReducer( + state: EnableCheckState, + action: EnableCheckAction +): EnableCheckState { + switch (state.status) { + case 'idle': + if (action.type === 'START_CHECK') return { status: 'checking' } + return state + case 'checking': + if (action.type === 'ENTITIES_FOUND') + return { status: 'confirming', unsafeEntities: action.unsafeEntities } + if (action.type === 'DISMISS') return { status: 'idle' } + return state + case 'confirming': + if (action.type === 'DISMISS') return { status: 'idle' } + return state + } +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx new file mode 100644 index 0000000000000..cdb8e2ab8bbdc --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchForm.tsx @@ -0,0 +1,78 @@ +import type { UseFormReturn } from 'react-hook-form' +import { + CardContent, + CardFooter, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + FormItem_Shadcn_, + Switch, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { DataApiDisabledAlert } from './DataApiDisabledAlert' +import type { DataApiFormValues } from './DataApiEnableSwitch.types' +import { FormActions } from '@/components/ui/Forms/FormActions' + +export const DataApiEnableSwitchForm = ({ + form, + formId, + disabled, + isBusy, + permissionsHelper, + onSubmit, + handleReset, +}: { + form: UseFormReturn + formId: string + disabled: boolean + isBusy: boolean + permissionsHelper: string | undefined + onSubmit: (values: DataApiFormValues) => void + handleReset: () => void +}) => { + const watchedEnabled = form.watch('enableDataApi') + + return ( + +
+ + ( + + + + + + + + {!watchedEnabled && } + + )} + /> + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx new file mode 100644 index 0000000000000..4fcf33d557a89 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiEnableSwitchStates.tsx @@ -0,0 +1,17 @@ +import { AlertCircle } from 'lucide-react' +import { Alert_Shadcn_, AlertTitle_Shadcn_, CardContent } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +export const DataApiEnableSwitchLoading = () => ( + + + + +) + +export const DataApiEnableSwitchError = () => ( + + + Failed to retrieve Data API settings + +) diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx new file mode 100644 index 0000000000000..bbee03d3ba896 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.tsx @@ -0,0 +1,106 @@ +import { useParams } from 'common' +import { AlertCircle } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { useEffect } from 'react' +import { Alert_Shadcn_, AlertTitle_Shadcn_ } from 'ui' +import { + PageSection, + PageSectionAside, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { DatabaseSelector } from '@/components/ui/DatabaseSelector' +import { useCustomDomainsQuery } from '@/data/custom-domains/custom-domains-query' +import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' +import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' +import { getApiEndpoint } from './DataApiProjectUrlCard.utils' + +export const DataApiProjectUrlCard = () => { + const { isPending: isLoading } = useSelectedProjectQuery() + const { ref: projectRef } = useParams() + const state = useDatabaseSelectorStateSnapshot() + + const [querySource, setQuerySource] = useQueryState('source', parseAsString) + + const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) + const { + data: databases, + isError, + isPending: isLoadingDatabases, + } = useReadReplicasQuery({ projectRef }) + const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) + + const syncSelectedDb = useStaticEffectEvent(() => { + if (querySource && querySource !== state.selectedDatabaseId) { + state.setSelectedDatabaseId(querySource) + } + }) + useEffect(() => { + syncSelectedDb() + }, [syncSelectedDb, querySource, projectRef]) + + const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) + const loadBalancerSelected = state.selectedDatabaseId === 'load-balancer' + const replicaSelected = selectedDatabase?.identifier !== projectRef + + const endpoint = getApiEndpoint({ + selectedDatabaseId: state.selectedDatabaseId, + projectRef, + customDomainData, + loadBalancers, + selectedDatabase, + }) + + return ( + + + + API URL + + {loadBalancerSelected + ? 'RESTful endpoint for querying and managing your databases through your load balancer' + : replicaSelected + ? 'RESTful endpoint for querying your read replica' + : 'RESTful endpoint for querying and managing your database'} + + + + 0 + ? [{ id: 'load-balancer', name: 'API Load Balancer' }] + : [] + } + onSelectId={() => { + setQuerySource(null) + }} + /> + + + + {isLoading || isLoadingDatabases ? ( +
+ + +
+ ) : isError ? ( + + + Failed to retrieve project URL + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts new file mode 100644 index 0000000000000..56bdea65cc6f2 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import { getApiEndpoint } from './DataApiProjectUrlCard.utils' +import type { + CustomDomainResponse, + CustomDomainsData, +} from '@/data/custom-domains/custom-domains-query' +import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query' +import type { Database } from '@/data/read-replicas/replicas-query' + +const makeCustomDomainData = (hostname: string): CustomDomainsData => ({ + customDomain: { + id: '', + ssl: {} as CustomDomainResponse['ssl'], + hostname, + status: 'active', + created_at: '', + custom_metadata: null, + custom_origin_server: '', + }, + status: '5_services_reconfigured', +}) + +const makeDatabase = ( + identifier: string, + restUrl: string +): Pick => ({ identifier, restUrl }) + +const makeLoadBalancer = (endpoint: string): Pick => ({ endpoint }) + +describe('getApiEndpoint', () => { + it('returns custom domain URL when custom domain is active and primary database is selected', () => { + expect( + getApiEndpoint({ + selectedDatabaseId: 'project-ref', + projectRef: 'project-ref', + customDomainData: makeCustomDomainData('api.example.com'), + loadBalancers: undefined, + selectedDatabase: makeDatabase( + 'project-ref', + 'https://project-ref.supabase.co/rest/v1' + ) as Database, + }) + ).toBe('https://api.example.com') + }) + + it('returns database restUrl when custom domain is active but a replica is selected', () => { + expect( + getApiEndpoint({ + selectedDatabaseId: 'replica-1', + projectRef: 'project-ref', + customDomainData: makeCustomDomainData('api.example.com'), + loadBalancers: undefined, + selectedDatabase: makeDatabase( + 'replica-1', + 'https://replica-1.supabase.co/rest/v1' + ) as Database, + }) + ).toBe('https://replica-1.supabase.co/rest/v1') + }) + + it('returns load balancer endpoint when load balancer is selected', () => { + expect( + getApiEndpoint({ + selectedDatabaseId: 'load-balancer', + projectRef: 'project-ref', + customDomainData: undefined, + loadBalancers: [makeLoadBalancer('https://lb.supabase.co') as LoadBalancer], + selectedDatabase: undefined, + }) + ).toBe('https://lb.supabase.co') + }) + + it('returns empty string when load balancer is selected but none exist', () => { + expect( + getApiEndpoint({ + selectedDatabaseId: 'load-balancer', + projectRef: 'project-ref', + customDomainData: undefined, + loadBalancers: undefined, + selectedDatabase: undefined, + }) + ).toBe('') + }) + + it('returns database restUrl for a normal database selection', () => { + expect( + getApiEndpoint({ + selectedDatabaseId: 'project-ref', + projectRef: 'project-ref', + customDomainData: undefined, + loadBalancers: undefined, + selectedDatabase: makeDatabase( + 'project-ref', + 'https://project-ref.supabase.co/rest/v1' + ) as Database, + }) + ).toBe('https://project-ref.supabase.co/rest/v1') + }) + + it('ignores custom domain when it is not active', () => { + const inactiveCustomDomain: CustomDomainsData = { + customDomain: null, + status: '0_no_hostname_configured', + } + + expect( + getApiEndpoint({ + selectedDatabaseId: 'project-ref', + projectRef: 'project-ref', + customDomainData: inactiveCustomDomain, + loadBalancers: undefined, + selectedDatabase: makeDatabase( + 'project-ref', + 'https://project-ref.supabase.co/rest/v1' + ) as Database, + }) + ).toBe('https://project-ref.supabase.co/rest/v1') + }) +}) diff --git a/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts new file mode 100644 index 0000000000000..880dbbb6c4fef --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/DataApiProjectUrlCard.utils.ts @@ -0,0 +1,34 @@ +import type { CustomDomainsData } from '@/data/custom-domains/custom-domains-query' +import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query' +import type { Database } from '@/data/read-replicas/replicas-query' + +/** + * Resolves the API endpoint URL based on the selected database, custom domain + * status, and load balancer configuration. + */ +export function getApiEndpoint({ + selectedDatabaseId, + projectRef, + customDomainData, + loadBalancers, + selectedDatabase, +}: { + selectedDatabaseId: string | undefined + projectRef: string | undefined + customDomainData: CustomDomainsData | undefined + loadBalancers: Array | undefined + selectedDatabase: Database | undefined +}): string { + const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' + const loadBalancerSelected = selectedDatabaseId === 'load-balancer' + + if (isCustomDomainActive && selectedDatabaseId === projectRef) { + return `https://${customDomainData.customDomain.hostname}` + } + + if (loadBalancerSelected) { + return loadBalancers?.[0]?.endpoint ?? '' + } + + return selectedDatabase?.restUrl ?? '' +} diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 30360a645b038..5a1da6e630977 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -1,32 +1,17 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { DocsButton } from 'components/ui/DocsButton' -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 { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { DOCS_URL } from 'lib/constants' import { indexOf } from 'lodash' import { Lock } from 'lucide-react' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, Button, Card, CardContent, CardFooter, - CardHeader, - Collapsible_Shadcn_, - CollapsibleContent_Shadcn_, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -34,10 +19,8 @@ import { Input_Shadcn_, PrePostTab, Skeleton, - Switch, - WarningIcon, } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' +import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { @@ -50,32 +33,25 @@ import { import { z } from 'zod' import { HardenAPIModal } from './HardenAPIModal' +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 { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -const formSchema = z - .object({ - 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(), - enableDataApi: z.boolean(), - }) - .refine( - (data) => { - if (data.enableDataApi && data.dbSchema.length === 0) { - return false - } - return true - }, - { - message: 'Must have at least one schema if Data API is enabled', - path: ['dbSchema'], - } - ) +const formSchema = z.object({ + 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(), +}) export const PostgrestConfig = () => { const { ref: projectRef } = useParams() @@ -128,16 +104,18 @@ export const PostgrestConfig = () => { const isGraphqlExtensionEnabled = (extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null - const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : [] - const defaultValues = { - dbSchema, - maxRows: config?.max_rows, - dbExtraSearchPath: (config?.db_extra_search_path ?? '') - .split(',') - .map((x) => x.trim()) - .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)), - dbPool: config?.db_pool, - } + const defaultValues = useMemo(() => { + const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : [] + return { + dbSchema, + maxRows: config?.max_rows, + dbExtraSearchPath: (config?.db_extra_search_path ?? '') + .split(',') + .map((x) => x.trim()) + .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)), + dbPool: config?.db_pool, + } + }, [config, allSchemas]) const form = useForm>({ resolver: zodResolver(formSchema), @@ -160,10 +138,9 @@ export const PostgrestConfig = () => { } }) ?? [] - function resetForm() { - const enableDataApi = config?.db_schema ? true : false - form.reset({ ...defaultValues, enableDataApi }) - } + const resetForm = useCallback(() => { + form.reset({ ...defaultValues }) + }, [form, defaultValues]) const onSubmit = async (values: z.infer) => { if (!projectRef) return console.error('Project ref is required') // is this needed ? @@ -179,91 +156,26 @@ export const PostgrestConfig = () => { useEffect(() => { if (config && isSuccessSchemas) { - /** - * Checks if enableDataApi should be enabled or disabled - * based on the db_schema value being empty string - */ resetForm() } - }, [config, isSuccessSchemas]) - - const isDataApiEnabledInForm = form.getValues('enableDataApi') + }, [config, isSuccessSchemas, resetForm]) return ( - - - Data API Settings -
- - -
-
- -
- {isLoading ? ( - - - - ) : isError ? ( - - - - ) : ( - <> - - ( - - - - { - field.onChange(value) - if (!value) { - form.setValue('enableDataApi', false) - form.setValue('dbSchema', []) - } else { - form.setValue('enableDataApi', true) - form.setValue('dbSchema', dbSchema) - } - }} - /> - - - - {!field.value && ( - - - No schemas can be queried - -

- With this setting disabled, you will not be able to query any schemas - via the Data API. -

-

- You will see errors from the Postgrest endpoint - /rest/v1/. -

-
-
- )} -
- )} - /> -
- - + + + + + + {isLoading ? ( + + + + ) : isError ? ( + + + + ) : ( + <> { onValuesChange={field.onChange} values={field.value} size="small" - disabled={!canUpdatePostgrestConfig || !isDataApiEnabledInForm} + disabled={!canUpdatePostgrestConfig} > { onValuesChange={field.onChange} values={field.value} size="small" - disabled={!canUpdatePostgrestConfig || !isDataApiEnabledInForm} + disabled={!canUpdatePostgrestConfig} > { field.onChange(Number(e.target.value))} @@ -439,7 +351,7 @@ export const PostgrestConfig = () => { { )} /> - - - - )} -
-
- - - + + )} + + + + + +
+ + + +
+ +
+
+
+
+ setShowModal(false)} /> - + ) } diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index e4c4d00f50296..5fc24b9606bd6 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,55 +1,13 @@ import { AlertCircle } from 'lucide-react' -import { parseAsString, useQueryState } from 'nuqs' -import { useEffect } from 'react' +import { Alert_Shadcn_, AlertTitle_Shadcn_ } from 'ui' -import { useParams } from 'common' -import { ScaffoldSection } from 'components/layouts/Scaffold' -import { DatabaseSelector } from 'components/ui/DatabaseSelector' -import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' -import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' -import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { PROJECT_STATUS } from 'lib/constants' -import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' -import { Alert_Shadcn_, AlertTitle_Shadcn_, Badge, Card, CardContent, CardHeader } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' -import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' -import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { PostgrestConfig } from './PostgrestConfig' +import { ScaffoldSection } from '@/components/layouts/Scaffold' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from '@/lib/constants' export const ServiceList = () => { const { data: project, isPending: isLoading } = useSelectedProjectQuery() - const { ref: projectRef } = useParams() - const state = useDatabaseSelectorStateSnapshot() - - const [querySource, setQuerySource] = useQueryState('source', parseAsString) - - const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const { - data: databases, - isError, - isPending: isLoadingDatabases, - } = useReadReplicasQuery({ projectRef }) - const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) - - useEffect(() => { - if (querySource && querySource !== state.selectedDatabaseId) { - state.setSelectedDatabaseId(querySource) - } - }, [querySource, state, projectRef]) - - // Get the API service - const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' - const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) - const loadBalancerSelected = state.selectedDatabaseId === 'load-balancer' - const replicaSelected = selectedDatabase?.identifier !== projectRef - - const endpoint = - isCustomDomainActive && state.selectedDatabaseId === projectRef - ? `https://${customDomainData.customDomain.hostname}` - : loadBalancerSelected - ? loadBalancers?.[0].endpoint ?? '' - : selectedDatabase?.restUrl return ( @@ -61,62 +19,7 @@ export const ServiceList = () => { ) : ( - <> - - - Project URL - 0 - ? [{ id: 'load-balancer', name: 'API Load Balancer' }] - : [] - } - onSelectId={() => { - setQuerySource(null) - }} - /> - - - {isLoading || isLoadingDatabases ? ( -
- - -
- ) : isError ? ( - - - Failed to retrieve project URL - - ) : ( - -

URL

- Custom domain active - - ) : ( - 'URL' - ) - } - description={ - loadBalancerSelected - ? 'RESTful endpoint for querying and managing your databases through your load balancer' - : replicaSelected - ? 'RESTful endpoint for querying your read replica' - : 'RESTful endpoint for querying and managing your database' - } - className="[&>div]:xl:w-1/2 [&>div>div]:w-full" - > - -
- )} -
-
- - - + )}
) diff --git a/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx b/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx new file mode 100644 index 0000000000000..2c022c962dfba --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/UnsafeEntitiesConfirmModal.tsx @@ -0,0 +1,178 @@ +import { ChevronRight, ChevronUp } from 'lucide-react' +import { useMemo, useState } from 'react' +import { + Button, + Collapsible_Shadcn_ as Collapsible, + CollapsibleContent_Shadcn_ as CollapsibleContent, + CollapsibleTrigger_Shadcn_ as CollapsibleTrigger, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +import { type ExposedEntity } from './DataApiEnableSwitch.utils' + +interface UnsafeEntitiesConfirmModalProps { + visible: boolean + loading: boolean + unsafeEntities: Array + onCancel: () => void + onConfirm: () => void +} + +const ENTITY_TYPE_META: Record< + ExposedEntity['type'], + { heading: string; recommendation: string; docsUrl: string } +> = { + table: { + heading: 'Tables without Row Level Security', + recommendation: 'Enable RLS on these tables to control access per-row.', + docsUrl: + 'https://supabase.com/docs/guides/database/database-linter?lint=0013_rls_disabled_in_public', + }, + 'foreign table': { + heading: 'Foreign tables', + recommendation: + 'Foreign tables do not support RLS. Revoke access from the anon and authenticated roles.', + docsUrl: + 'https://supabase.com/docs/guides/database/database-linter?lint=0017_foreign_table_in_api', + }, + 'materialized view': { + heading: 'Materialized views', + recommendation: + 'Materialized views do not support RLS. Revoke access from the anon and authenticated roles.', + docsUrl: + 'https://supabase.com/docs/guides/database/database-linter?lint=0016_materialized_view_in_api', + }, + view: { + heading: 'Views without SECURITY INVOKER', + recommendation: + 'These views run with the permissions of the view creator, not the querying user. Set SECURITY INVOKER to enforce caller permissions.', + docsUrl: + 'https://supabase.com/docs/guides/database/database-linter?lint=0010_security_definer_view', + }, +} + +const ENTITY_TYPE_ORDER: Array = [ + 'table', + 'foreign table', + 'materialized view', + 'view', +] + +const COLLAPSE_THRESHOLD = 3 + +export const UnsafeEntitiesConfirmModal = ({ + visible, + loading, + unsafeEntities, + onCancel, + onConfirm, +}: UnsafeEntitiesConfirmModalProps) => { + const groupedEntities = useMemo(() => { + const groups = new Map>() + for (const entity of unsafeEntities) { + const group = groups.get(entity.type) + if (group) { + group.push(entity) + } else { + groups.set(entity.type, [entity]) + } + } + return ENTITY_TYPE_ORDER.filter((type) => groups.has(type)).map((type) => ({ + type, + ...ENTITY_TYPE_META[type], + entities: groups.get(type) ?? [], + })) + }, [unsafeEntities]) + + return ( + +
+

+ The following objects will be publicly accessible through the Data API and are insecure. +

+ {groupedEntities.map(({ type, heading, recommendation, docsUrl, entities }) => ( +
+

{heading}

+ +

+ {recommendation}{' '} + + Learn more + +

+
+ ))} +
+
+ ) +} + +const EntityListItem = ({ entity }: { entity: ExposedEntity }) => ( +
  • + + {entity.schema}.{entity.name} + +
  • +) + +const EntityList = ({ entities }: { entities: Array }) => { + const [open, setOpen] = useState(false) + + const shouldCollapse = entities.length > COLLAPSE_THRESHOLD + const visibleEntities = entities.slice(0, COLLAPSE_THRESHOLD) + const hiddenEntities = entities.slice(COLLAPSE_THRESHOLD) + + if (!shouldCollapse) { + return ( +
      + {entities.map((entity) => ( + + ))} +
    + ) + } + + return ( + +
      + {visibleEntities.map((entity) => ( + + ))} +
    + +
      + {hiddenEntities.map((entity) => ( + + ))} +
    +
    + + + +
    + ) +} diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 41f0be4aa73bc..cd82c7aa6a5f5 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -80,13 +80,6 @@ export const generateSettingsMenu = ( disabled: !isProjectActive, }, - { - name: 'Data API', - key: 'api', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/api`, - items: [], - disabled: !isProjectActive, - }, { name: 'API Keys', key: 'api-keys', @@ -117,6 +110,18 @@ export const generateSettingsMenu = ( url: `/project/${ref}/settings/addons`, items: [], }, + ], + }, + { + title: 'Configuration', + items: [ + { + name: 'Data API', + key: 'api', + url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/data_api/overview`, + items: [], + rightIcon: , + }, { name: 'Vault', key: 'vault', diff --git a/apps/studio/data/queries/sql/tables-without-rls.ts b/apps/studio/data/queries/sql/tables-without-rls.ts new file mode 100644 index 0000000000000..6251d1671f659 --- /dev/null +++ b/apps/studio/data/queries/sql/tables-without-rls.ts @@ -0,0 +1,73 @@ +import { quoteLiteral } from '@/lib/pg-format' + +/** + * Builds a SQL query that returns entities exposed through the Data API that + * have potential security issues: + * + * - Tables without Row Level Security (lint 0013) + * - Foreign tables accessible by anon/authenticated (lint 0017) + * - Materialized views accessible by anon/authenticated (lint 0016) + * - Views without SECURITY INVOKER on PG 15+ (lint 0010) + * + * Checks against the _target_ schemas rather than the currently active + * PostgREST config, so it works correctly when enabling the Data API. + */ +export const unsafeEntitiesInApiSql = (schemas: Array) => { + const schemaList = schemas.map(quoteLiteral).join(', ') + + return /* SQL */ ` + select + n.nspname as schema, + c.relname as name, + case c.relkind + when 'r' then 'table' + when 'f' then 'foreign table' + when 'm' then 'materialized view' + when 'v' then 'view' + end as type + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n on c.relnamespace = n.oid + left join pg_catalog.pg_depend dep + on c.oid = dep.objid + and dep.deptype = 'e' + where + ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname in (${schemaList}) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', + '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', + 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', + 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', + 'realtime', 'repack', 'storage', 'supabase_functions', + 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + and dep.objid is null + and ( + -- Tables without RLS + (c.relkind = 'r' and not c.relrowsecurity) + -- Foreign tables (RLS not supported) + or c.relkind = 'f' + -- Materialized views (RLS not supported) + or c.relkind = 'm' + -- Views without security invoker (PG 15+) + or ( + c.relkind = 'v' + and substring(pg_catalog.version() from 'PostgreSQL ([0-9]+)') >= '15' + and not ( + lower(coalesce(c.reloptions::text, '{}'))::text[] + && array[ + 'security_invoker=1', + 'security_invoker=true', + 'security_invoker=yes', + 'security_invoker=on' + ] + ) + ) + ) + order by n.nspname, c.relname + ` +} diff --git a/apps/studio/hooks/misc/useIsDataApiEnabled.ts b/apps/studio/hooks/misc/useIsDataApiEnabled.ts new file mode 100644 index 0000000000000..cb3662c8bd20a --- /dev/null +++ b/apps/studio/hooks/misc/useIsDataApiEnabled.ts @@ -0,0 +1,15 @@ +import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' + +/** + * Returns whether the Data API is enabled for the given project. + * + * The Data API is considered enabled when the PostgREST `db_schema` config + * contains at least one non-empty schema name. + */ +export const useIsDataApiEnabled = ({ projectRef }: { projectRef?: string }) => { + const { data: config, ...rest } = useProjectPostgrestConfigQuery({ projectRef }) + + const isEnabled = !!config?.db_schema?.trim() + + return { ...rest, data: isEnabled, isEnabled } +} diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 29599dc5d512e..b440009d0d93c 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -439,6 +439,11 @@ const nextConfig = { destination: '/project/:ref/auth/providers', permanent: true, }, + { + source: '/project/:ref/settings/api', + destination: '/project/:ref/integrations/data_api/overview', + permanent: false, + }, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index ee4fdac390509..22971a6f31703 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -1,4 +1,5 @@ import { useFlag, useParams } from 'common' +import { IS_PLATFORM } from 'lib/constants' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from 'components/layouts/DefaultLayout' @@ -135,6 +136,10 @@ const IntegrationPage: NextPageWithLayout = () => { return null } + if (id === 'data_api' && !IS_PLATFORM) { + return + } + if (id === 'stripe_sync_engine' && !stripeSyncEnabled) { return } diff --git a/apps/studio/pages/project/[ref]/settings/api.tsx b/apps/studio/pages/project/[ref]/settings/api.tsx index 2914082b6a74d..7c9cfd014053a 100644 --- a/apps/studio/pages/project/[ref]/settings/api.tsx +++ b/apps/studio/pages/project/[ref]/settings/api.tsx @@ -1,23 +1,24 @@ -import { ServiceList } from 'components/interfaces/Settings/API/ServiceList' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { PageLayout } from 'components/layouts/PageLayout/PageLayout' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { ScaffoldContainer } from 'components/layouts/Scaffold' -import type { NextPageWithLayout } from 'types' +import { useParams } from 'common' +import { IS_PLATFORM } from 'lib/constants' +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import type { NextPageWithLayout } from '@/types' const ApiSettings: NextPageWithLayout = () => { - return ( - - - - ) + const router = useRouter() + const { ref } = useParams() + + useEffect(() => { + if (!ref) return + if (IS_PLATFORM) { + router.replace(`/project/${ref}/integrations/data_api/overview`) + } else { + router.replace(`/project/${ref}/settings/general`) + } + }, [ref, router]) + + return null } -ApiSettings.getLayout = (page) => ( - - - {page} - - -) export default ApiSettings diff --git a/apps/studio/static-data/integrations/data_api/overview.md b/apps/studio/static-data/integrations/data_api/overview.md new file mode 100644 index 0000000000000..028af1154b6bc --- /dev/null +++ b/apps/studio/static-data/integrations/data_api/overview.md @@ -0,0 +1,4 @@ +The Supabase Data API exposes REST endpoints for your project's databases, including read replicas +and load balancers. + +Configure the API base URL, custom domains, and PostgREST settings for your project.