From 0cb71a24976e8add3110f509332a5561bbb59e28 Mon Sep 17 00:00:00 2001 From: Raminder Singh Date: Thu, 16 Apr 2026 15:27:12 +0530 Subject: [PATCH] feat: new marketplace db (#44574) This PR integrates with the new marketplace db to allow Grafana (and other partners) OAuth apps to install from the integrations page. A demo of this working locally is available here: https://supabase.slack.com/archives/C01GN60J0BS/p1775551752479709. End to end flow is documented here: https://www.notion.so/supabase/Grafana-Integration-Flow-33a5004b775f80eeaf91c098beb8071f. TODO: - [ ] Make sure `NEXT_PUBLIC_MARKETPLACE_API_URL` variable is set to the new marketplace db. - [x] Test with the `marketplaceIntegrations` enabled and disabled in staging once https://github.com/supabase/platform/pull/31298 is merged and available in staging. ## Summary by CodeRabbit * **New Features** * Add OAuth "Install integration" button that detects installed integrations and supports GET/POST install flows * Marketplace listings now include install links, installation method, partner info, and listing assets/logos * **Infrastructure** * Allow marketplace API origin for images and content in security and image config * Centralize marketplace types and switch marketplace data source for more reliable listings --------- Co-authored-by: Joshen Lim --- .../IntegrationOverviewTabV2/FilesViewer.tsx | 55 ++- .../InstallOAuthIntegrationButton.tsx | 81 ++++ .../Landing/Integrations.constants.tsx | 11 + .../Landing/useAvailableIntegrations.tsx | 161 ++++--- .../layouts/ProjectIntegrationsLayout.tsx | 4 +- apps/studio/csp.ts | 6 + .../install-oauth-integration-mutation.ts | 52 ++ .../integration-categories-query.ts | 6 +- .../data/marketplace/integrations-query.ts | 11 +- .../data/marketplace/marketplace.types.ts | 453 ------------------ apps/studio/next.config.ts | 21 + .../integrations/[id]/[pageId]/index.tsx | 7 +- packages/api-types/types/platform.d.ts | 93 ++++ .../common}/marketplace-client.ts | 0 packages/common/marketplace.types.ts | 243 ++++++++++ 15 files changed, 651 insertions(+), 553 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx create mode 100644 apps/studio/data/marketplace/install-oauth-integration-mutation.ts delete mode 100644 apps/studio/data/marketplace/marketplace.types.ts rename {apps/studio/data/marketplace => packages/common}/marketplace-client.ts (100%) create mode 100644 packages/common/marketplace.types.ts diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer.tsx index 6ece9fab583..7ed57d47876 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/FilesViewer.tsx @@ -1,28 +1,43 @@ import { useState } from 'react' -import { cn } from 'ui' +import { cn, Dialog, DialogContent } from 'ui' export const FilesViewer = ({ files }: { files: string[] }) => { const [selected, setSelected] = useState(files[0]) + const [showDialog, setShowDialog] = useState(false) return ( -
- {selected} - {files.length > 1 && ( -
- {files.map((x) => ( - - ))} -
- )} -
+ <> +
+ + + {files.length > 1 && ( +
+ {files.map((x) => ( + + ))} +
+ )} +
+ + + {selected} + + + ) } diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx new file mode 100644 index 00000000000..663288b0323 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx @@ -0,0 +1,81 @@ +import { useParams } from 'common' +import { useMemo } from 'react' +import { toast } from 'sonner' +import { Button } from 'ui' + +import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' +import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query' +import { useInstallOAuthIntegrationMutation } from '@/data/marketplace/install-oauth-integration-mutation' + +interface InstallOAuthIntegrationButtonProps { + integration: IntegrationDefinition +} + +export function InstallOAuthIntegrationButton({ integration }: InstallOAuthIntegrationButtonProps) { + const { ref: projectRef } = useParams() + + const { data: apiKeys, isLoading: isApiKeysLoading } = useAPIKeysQuery( + { projectRef, reveal: false }, + { enabled: !!projectRef } + ) + + const { mutate: installOAuthIntegration, isPending: isInstalling } = + useInstallOAuthIntegrationMutation({ + onSuccess: (data) => { + if ('redirectUrl' in data) { + if (!data.redirectUrl) { + toast.error('Failed to redirect because redirect URL is invalid') + return + } + window.location.href = data.redirectUrl + } else { + toast.error('Failed to start integration installation') + } + }, + }) + + const isLoading = + integration.installIdentificationMethod === 'secret_key_prefix' && isApiKeysLoading + + const isIntegrationInstalled = useMemo(() => { + if (!integration) return false + + const prefix = integration.secretKeyPrefix + + if (integration.installIdentificationMethod !== 'secret_key_prefix' || !prefix) return false + if (isApiKeysLoading || !apiKeys) return false + + return apiKeys.some((k) => k.type === 'secret' && k.name.startsWith(prefix)) + }, [apiKeys, integration, isApiKeysLoading]) + + const handleInstallClick = async () => { + if (!integration || !projectRef) return + + if (integration.installUrlType === 'post') { + if (!integration.listingId) return toast.error('Listing ID is required') + installOAuthIntegration({ projectRef, id: integration.listingId }) + } else { + window.location.href = integration.installUrl ?? '/' + } + } + + return ( + <> + {isIntegrationInstalled ? ( + + ) : ( + + )} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index 3e1a8d1d994..186d7e4bbd7 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -46,6 +46,10 @@ type IntegrationStep = { description?: string } +type InstallUrlType = 'get' | 'post' + +type InstallIdentificationMethod = 'secret_key_prefix' + /** * [Joshen] For marketplace, we probably need to revisit this definition * What properties are obsolete, what properties we need from remote source @@ -97,6 +101,13 @@ export type IntegrationDefinition = { inputs?: IntegrationInputs /** Purely visual, just to show what are the changes on the project from installing the integration */ steps?: IntegrationStep[] + + /** These are for OAuth Integrations */ + installUrl?: string | null + installUrlType?: InstallUrlType + installIdentificationMethod?: InstallIdentificationMethod + secretKeyPrefix?: string + listingId?: string } & ( | { type: 'wrapper'; meta: WrapperMeta } | { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' } diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index 33bafdfae6a..f65a769b7c4 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { FeatureFlagContext, IS_PLATFORM, useFlag } from 'common' import { Boxes } from 'lucide-react' import dynamic from 'next/dynamic' +import Image from 'next/image' import { useContext, useMemo } from 'react' import { cn } from 'ui' @@ -10,6 +11,11 @@ import { marketplaceIntegrationsQueryOptions } from '@/data/marketplace/integrat import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +const fullImageUrl = (imagePath: string) => { + const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || '' + return `${API_URL}${imagePath}` +} + /** * [Joshen] Returns a combination of * - Marketplace integrations retrieved remotely (Only if feature flag enabled) @@ -33,69 +39,93 @@ export const useAvailableIntegrations = () => { // [Joshen] Format marketplace integrations into existing ones for now // Likely that we might need to change, but can look into separately - const marketplaceIntegrations: IntegrationDefinition[] = (data ?? [])?.map((integration) => { - const { - id, - type, - categories, - title: name, - summary: description, - documentation_url: docsUrl, - url: siteUrl, - content, - files, - } = integration + const marketplaceIntegrations: IntegrationDefinition[] = useMemo( + () => + (data ?? [])?.map((integration) => { + const { + id: listingId, + slug, + categories, + title, + description, + documentation_url: docsUrl, + website_url: siteUrl, + installation_url: installUrl, + installation_url_type: installUrlType, + installation_identification_method: installMethod, + secret_key_prefix: secretKeyPrefix, + images, + content, + partner_name: authorName, + listing_logo: listingLogo, + } = integration - const status = undefined - const author = { name: '', websiteUrl: '' } + const status = undefined + const author = { name: authorName ?? '', websiteUrl: '' } - return { - id: id.toString(), - name, - status, - type, - categories: categories.map((x) => x.slug), - content, - files, - description, - docsUrl, - siteUrl, - author, - requiredExtensions: [], - icon: ({ className, ...props } = {}) => ( - - ), - navigation: [ - { - route: 'overview', - label: 'Overview', - }, - ], - navigate: ({ pageId = 'overview' }) => { - switch (pageId) { - case 'overview': - return dynamic( - () => - import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then( - (mod) => mod.IntegrationOverviewTabV2 - ), - { - loading: Loading, - } - ) - case 'secrets': - return dynamic( - () => - import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement), - { - loading: Loading, - } - ) + return { + id: slug ?? '', + name: title ?? '', + status, + type: 'oauth' as const, // Currently marketplace only supports oauth apps + categories: Array.isArray(categories) + ? (categories as Array<{ slug: string }>).map((x) => x.slug) + : [], + content, + files: images?.map((image) => fullImageUrl(image)), + description, + docsUrl, + siteUrl, + installUrl, + installUrlType: installUrlType ?? undefined, + installIdentificationMethod: installMethod ?? undefined, + secretKeyPrefix: secretKeyPrefix ?? undefined, + listingId: listingId ?? undefined, + author, + requiredExtensions: [], + icon: ({ className, ...props } = {}) => ( +
+ {listingLogo ? ( + + ) : ( + + )} +
+ ), + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + ], + navigate: ({ pageId = 'overview' }) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then( + (mod) => mod.IntegrationOverviewTabV2 + ), + { + loading: Loading, + } + ) + } + return null + }, } - return null - }, - } - }) + }), + [data] + ) // [Joshen] Existing integrations that are defined within studio // Available integrations are all integrations that can be installed. If an integration can't be installed (needed @@ -117,13 +147,14 @@ export const useAvailableIntegrations = () => { }) }, [integrationsWrappers, isCLI]) - const availableIntegrations = useMemo( - () => allIntegrations.sort((a, b) => a.name.localeCompare(b.name)), - [allIntegrations] - ) + const dataWithMarketplace = useMemo(() => { + return [...marketplaceIntegrations, ...allIntegrations].sort((a, b) => + a.name.localeCompare(b.name) + ) + }, [marketplaceIntegrations, allIntegrations]) return { - data: [...marketplaceIntegrations, ...availableIntegrations], + data: dataWithMarketplace, error, isPending, isSuccess, diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx index bc6c161d74a..fffae6d0743 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout.tsx @@ -85,8 +85,8 @@ const IntegrationCategoriesMenu = ({ page }: { page: string }) => { items: [], }, ...categories.map((category) => ({ - name: category.title, - key: category.slug, + name: category.name ?? '', + key: category.slug ?? '', url: `/project/${ref}/integrations?category=${category.slug}`, items: [], })), diff --git a/apps/studio/csp.ts b/apps/studio/csp.ts index c3dd416efc7..9c6218254c2 100644 --- a/apps/studio/csp.ts +++ b/apps/studio/csp.ts @@ -5,6 +5,10 @@ const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin : '' +const MARKETPLACE_API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL + ? new URL(process.env.NEXT_PUBLIC_MARKETPLACE_API_URL).origin + : '' + const SUPABASE_PROJECTS_URL = 'https://*.supabase.co https://*.storage.supabase.co' const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co' @@ -85,6 +89,7 @@ export function getCSP() { API_URL, SUPABASE_URL, GOTRUE_URL, + MARKETPLACE_API_URL, SUPABASE_LOCAL_PROJECTS_URL_WS, SUPABASE_PROJECTS_URL, SUPABASE_PROJECTS_URL_WS, @@ -130,6 +135,7 @@ export function getCSP() { USERCENTRICS_APP_URL, STAPE_URL, USERCENTRICS_URLS, + MARKETPLACE_API_URL, ...(!!NIMBUS_PROD_PROJECTS_URL ? [NIMBUS_PROD_PROJECTS_URL, NIMBUS_PROD_PROJECTS_URL_WS] : []), ] const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL] diff --git a/apps/studio/data/marketplace/install-oauth-integration-mutation.ts b/apps/studio/data/marketplace/install-oauth-integration-mutation.ts new file mode 100644 index 00000000000..30108618770 --- /dev/null +++ b/apps/studio/data/marketplace/install-oauth-integration-mutation.ts @@ -0,0 +1,52 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from '@/data/fetchers' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthIntegrationInstallVariables = { + projectRef: string + id: string +} + +export async function installOAuthIntegration({ + projectRef, + id, +}: OAuthIntegrationInstallVariables) { + const { data, error } = await post('/platform/integrations/partners/{ref}/{listing_id}', { + params: { path: { ref: projectRef, listing_id: id } }, + }) + + if (error) handleError(error) + return data +} + +type OAuthIntegrationInstallData = Awaited> + +export const useInstallOAuthIntegrationMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthIntegrationInstallData, + ResponseError, + OAuthIntegrationInstallVariables + >, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => installOAuthIntegration(vars), + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to start OAuth integration installation: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/marketplace/integration-categories-query.ts b/apps/studio/data/marketplace/integration-categories-query.ts index e86439ae4a9..06a45287454 100644 --- a/apps/studio/data/marketplace/integration-categories-query.ts +++ b/apps/studio/data/marketplace/integration-categories-query.ts @@ -1,12 +1,12 @@ import { queryOptions } from '@tanstack/react-query' +import { createMarketplaceClient } from 'common/marketplace-client' import { marketplaceIntegrationsKeys } from './keys' -import { createMarketplaceClient } from './marketplace-client' import { handleError } from '@/data/fetchers' async function getMarketplaceCategories() { - const client = createMarketplaceClient() - const { data, error } = await client.from('categories').select('*') + const marketplaceClient = createMarketplaceClient() + const { data, error } = await marketplaceClient.from('categories').select('*') if (error) handleError(error) return data ?? [] diff --git a/apps/studio/data/marketplace/integrations-query.ts b/apps/studio/data/marketplace/integrations-query.ts index 6114ed024c1..51a7db647ac 100644 --- a/apps/studio/data/marketplace/integrations-query.ts +++ b/apps/studio/data/marketplace/integrations-query.ts @@ -1,14 +1,15 @@ import { queryOptions } from '@tanstack/react-query' +import { createMarketplaceClient } from 'common/marketplace-client' import { marketplaceIntegrationsKeys } from './keys' -import { createMarketplaceClient } from './marketplace-client' import { handleError } from '@/data/fetchers' async function getMarketplaceIntegrations() { - const client = createMarketplaceClient() - const { data, error } = await client - .from('items') - .select('*, categories:category_items(...categories(slug, title))') + const marketplaceClient = createMarketplaceClient() + const { data, error } = await marketplaceClient + .from('listings') + .select('*') + .or('publish_location.eq.dashboard,publish_location.eq.both') if (error) handleError(error) return data ?? [] diff --git a/apps/studio/data/marketplace/marketplace.types.ts b/apps/studio/data/marketplace/marketplace.types.ts deleted file mode 100644 index 06d17342560..00000000000 --- a/apps/studio/data/marketplace/marketplace.types.ts +++ /dev/null @@ -1,453 +0,0 @@ -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] - -export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: '14.4' - } - graphql_public: { - Tables: { - [_ in never]: never - } - Views: { - [_ in never]: never - } - Functions: { - graphql: { - Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } - public: { - Tables: { - categories: { - Row: { - created_at: string - description: string | null - id: number - slug: string - title: string - updated_at: string - } - Insert: { - created_at?: string - description?: string | null - id?: never - slug: string - title: string - updated_at?: string - } - Update: { - created_at?: string - description?: string | null - id?: never - slug?: string - title?: string - updated_at?: string - } - Relationships: [] - } - category_items: { - Row: { - category_id: number - created_at: string - item_id: number - } - Insert: { - category_id: number - created_at?: string - item_id: number - } - Update: { - category_id?: number - created_at?: string - item_id?: number - } - Relationships: [ - { - foreignKeyName: 'category_items_category_id_fkey' - columns: ['category_id'] - isOneToOne: false - referencedRelation: 'categories' - referencedColumns: ['id'] - }, - { - foreignKeyName: 'category_items_item_id_fkey' - columns: ['item_id'] - isOneToOne: false - referencedRelation: 'items' - referencedColumns: ['id'] - }, - ] - } - item_reviews: { - Row: { - created_at: string - featured: boolean - item_id: number - published_at: string | null - review_notes: string | null - reviewed_at: string | null - reviewed_by: string | null - status: Database['public']['Enums']['marketplace_review_status'] - updated_at: string - } - Insert: { - created_at?: string - featured?: boolean - item_id: number - published_at?: string | null - review_notes?: string | null - reviewed_at?: string | null - reviewed_by?: string | null - status?: Database['public']['Enums']['marketplace_review_status'] - updated_at?: string - } - Update: { - created_at?: string - featured?: boolean - item_id?: number - published_at?: string | null - review_notes?: string | null - reviewed_at?: string | null - reviewed_by?: string | null - status?: Database['public']['Enums']['marketplace_review_status'] - updated_at?: string - } - Relationships: [ - { - foreignKeyName: 'item_reviews_item_id_fkey' - columns: ['item_id'] - isOneToOne: true - referencedRelation: 'items' - referencedColumns: ['id'] - }, - ] - } - items: { - Row: { - content: string | null - created_at: string - documentation_url: string | null - files: string[] - id: number - partner_id: number - published: boolean - registry_item_url: string | null - slug: string - submitted_by: string | null - summary: string | null - title: string - type: Database['public']['Enums']['marketplace_item_type'] - updated_at: string - url: string | null - } - Insert: { - content?: string | null - created_at?: string - documentation_url?: string | null - files?: string[] - id?: never - partner_id: number - published?: boolean - registry_item_url?: string | null - slug: string - submitted_by?: string | null - summary?: string | null - title: string - type: Database['public']['Enums']['marketplace_item_type'] - updated_at?: string - url?: string | null - } - Update: { - content?: string | null - created_at?: string - documentation_url?: string | null - files?: string[] - id?: never - partner_id?: number - published?: boolean - registry_item_url?: string | null - slug?: string - submitted_by?: string | null - summary?: string | null - title?: string - type?: Database['public']['Enums']['marketplace_item_type'] - updated_at?: string - url?: string | null - } - Relationships: [ - { - foreignKeyName: 'items_partner_id_fkey' - columns: ['partner_id'] - isOneToOne: false - referencedRelation: 'partners' - referencedColumns: ['id'] - }, - ] - } - partner_members: { - Row: { - created_at: string - partner_id: number - role: string - user_id: string - } - Insert: { - created_at?: string - partner_id: number - role?: string - user_id: string - } - Update: { - created_at?: string - partner_id?: number - role?: string - user_id?: string - } - Relationships: [ - { - foreignKeyName: 'partner_members_partner_id_fkey' - columns: ['partner_id'] - isOneToOne: false - referencedRelation: 'partners' - referencedColumns: ['id'] - }, - ] - } - partners: { - Row: { - created_at: string - created_by: string | null - description: string | null - id: number - logo_url: string | null - role: Database['public']['Enums']['marketplace_partner_role'] - slug: string - title: string - updated_at: string - website: string | null - } - Insert: { - created_at?: string - created_by?: string | null - description?: string | null - id?: never - logo_url?: string | null - role?: Database['public']['Enums']['marketplace_partner_role'] - slug: string - title: string - updated_at?: string - website?: string | null - } - Update: { - created_at?: string - created_by?: string | null - description?: string | null - id?: never - logo_url?: string | null - role?: Database['public']['Enums']['marketplace_partner_role'] - slug?: string - title?: string - updated_at?: string - website?: string | null - } - Relationships: [] - } - } - Views: { - [_ in never]: never - } - Functions: { - add_partner_member: { - Args: { - target_email: string - target_partner_id: number - target_role?: string - } - Returns: { - created_at: string - partner_id: number - role: string - user_id: string - } - SetofOptions: { - from: '*' - to: 'partner_members' - isOneToOne: true - isSetofReturn: false - } - } - before_user_created_hook: { Args: { event: Json }; Returns: Json } - is_admin_member: { Args: never; Returns: boolean } - is_partner_admin: { - Args: { target_partner_id: number } - Returns: boolean - } - is_partner_member: { - Args: { target_partner_id: number } - Returns: boolean - } - is_review_manager_member: { Args: never; Returns: boolean } - is_reviewer_member: { Args: never; Returns: boolean } - item_latest_review_is_approved: { - Args: { target_item_id: number } - Returns: boolean - } - storage_object_item_id: { Args: { object_name: string }; Returns: number } - storage_object_partner_id: { - Args: { object_name: string } - Returns: number - } - } - Enums: { - marketplace_item_type: 'oauth' | 'template' - marketplace_partner_role: 'partner' | 'reviewer' | 'admin' - marketplace_review_status: 'draft' | 'pending_review' | 'approved' | 'rejected' - } - CompositeTypes: { - [_ in never]: never - } - } -} - -type DatabaseWithoutInternals = Omit - -type DefaultSchema = DatabaseWithoutInternals[Extract] - -export type Tables< - DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { - Row: infer R - } - ? R - : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) - ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R - } - ? R - : never - : never - -export type TablesInsert< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema['Tables'] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { - Insert: infer I - } - ? I - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] - ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I - } - ? I - : never - : never - -export type TablesUpdate< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema['Tables'] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { - Update: infer U - } - ? U - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] - ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { - Update: infer U - } - ? U - : never - : never - -export type Enums< - DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema['Enums'] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] - : never = never, -> = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] - : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] - ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] - : never - -export type CompositeTypes< - PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema['CompositeTypes'] - | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] - : never = never, -> = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] - ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] - : never - -export const Constants = { - graphql_public: { - Enums: {}, - }, - public: { - Enums: { - marketplace_item_type: ['oauth', 'template'], - marketplace_partner_role: ['partner', 'reviewer', 'admin'], - marketplace_review_status: ['draft', 'pending_review', 'approved', 'rejected'], - }, - }, -} as const diff --git a/apps/studio/next.config.ts b/apps/studio/next.config.ts index c0ebf3c40e2..2f3b59c76cf 100644 --- a/apps/studio/next.config.ts +++ b/apps/studio/next.config.ts @@ -29,6 +29,17 @@ function getAssetPrefix() { return `${SUPABASE_ASSETS_URL}/${process.env.SITE_NAME}/${process.env.VERCEL_GIT_COMMIT_SHA?.substring(0, 12) ?? 'unknown'}` } +const marketplaceApiUrl = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL + ? new URL(process.env.NEXT_PUBLIC_MARKETPLACE_API_URL) + : null + +const marketplaceApiProtocol: 'http' | 'https' | null = + marketplaceApiUrl?.protocol === 'https:' + ? 'https' + : marketplaceApiUrl?.protocol === 'http:' + ? 'http' + : null + // Use `satisfies` instead of `: NextConfig` so TypeScript preserves narrow // inferred types (e.g. async headers → Promise). This avoids TS2345 when // wrapper functions (bundle-analyzer, sentry) resolve their `next` peer @@ -576,6 +587,16 @@ const nextConfig = { port: '', pathname: '**', }, + ...(marketplaceApiUrl + ? [ + { + ...(marketplaceApiProtocol ? { protocol: marketplaceApiProtocol } : {}), + hostname: marketplaceApiUrl.hostname, + port: marketplaceApiUrl.port, + pathname: '**', + }, + ] + : []), ], }, transpilePackages: [ 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 eb49a03adce..36f9b7567fa 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -29,6 +29,7 @@ import { import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { InstallIntegrationSheet } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallIntegrationSheet' +import { InstallOAuthIntegrationButton } from '@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton' import { useAvailableIntegrations } from '@/components/interfaces/Integrations/Landing/useAvailableIntegrations' import { useInstalledIntegrations } from '@/components/interfaces/Integrations/Landing/useInstalledIntegrations' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -208,11 +209,7 @@ const IntegrationPage: NextPageWithLayout = () => { {integration?.type === 'oauth' ? ( - + ) : isMarketplaceEnabled && !!integration && !isInstalled ? ( ) : isMarketplaceEnabled && isInstalled ? ( diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index d262b76b518..49825a991c5 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -670,6 +670,23 @@ export interface paths { patch?: never trace?: never } + '/platform/integrations/partners/{ref}/{listing_id}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Creates a partner integration and returns the redirect URL */ + post: operations['PartnerIntegrationsController_createIntegration'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/platform/integrations/private-link/{slug}': { parameters: { query?: never @@ -7918,6 +7935,23 @@ export interface components { organization_id: number overdue_invoice_count: number } + PartnerIntegrationsResponse: { + /** + * Format: date-time + * @description When the integration link expires (1 hour from creation). The user must begin the flow before this time. + */ + expiresAt: string + /** + * Format: uuid + * @description Unique identifier for the integration record + */ + integrationId: string + /** + * Format: uri + * @description URL to redirect the user's browser to + */ + redirectUrl: string + } PauseStatusResponse: { can_restore: boolean last_paused_on: string | null @@ -13210,6 +13244,65 @@ export interface operations { } } } + PartnerIntegrationsController_createIntegration: { + parameters: { + query?: never + header?: never + path: { + /** @description The id of the listing in the marketplace database */ + listing_id: string + /** @description Supabase project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['PartnerIntegrationsResponse'] + } + } + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Project or listing not found */ + 404: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to retrieve listing or project, or failed to create partner integration */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } PrivateLinkController_getPrivateLinkConfig: { parameters: { query?: never diff --git a/apps/studio/data/marketplace/marketplace-client.ts b/packages/common/marketplace-client.ts similarity index 100% rename from apps/studio/data/marketplace/marketplace-client.ts rename to packages/common/marketplace-client.ts diff --git a/packages/common/marketplace.types.ts b/packages/common/marketplace.types.ts new file mode 100644 index 00000000000..7799dce739a --- /dev/null +++ b/packages/common/marketplace.types.ts @@ -0,0 +1,243 @@ +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] + +export type Database = { + public: { + Tables: { + v_secret_id: { + Row: { + create_secret: string | null + } + Insert: { + create_secret?: string | null + } + Update: { + create_secret?: string | null + } + Relationships: [] + } + } + Views: { + categories: { + Row: { + description: string | null + id: string | null + name: string | null + slug: string | null + } + Insert: { + description?: string | null + id?: string | null + name?: string | null + slug?: string | null + } + Update: { + description?: string | null + id?: string | null + name?: string | null + slug?: string | null + } + Relationships: [] + } + listings: { + Row: { + aud: string | null + categories: Json | null + content: string | null + description: string | null + documentation_url: string | null + featured: boolean | null + id: string | null + images: string[] | null + installation_identification_method: 'secret_key_prefix' | null + installation_url: string | null + installation_url_type: 'get' | 'post' | null + listing_logo: string | null + marketplace_url: string | null + partner_logo: string | null + partner_name: string | null + partner_slug: string | null + publish_location: 'marketplace' | 'dashboard' | 'both' | null + secret_key_prefix: string | null + slug: string | null + title: string | null + website_url: string | null + youtube_id: string | null + } + Relationships: [] + } + partners: { + Row: { + country: string | null + description: string | null + id: string | null + logo: string | null + name: string | null + num_of_employees: number | null + slug: string | null + type: 'technology' | 'expert' | null + website: string | null + } + Insert: { + country?: string | null + description?: string | null + id?: string | null + logo?: never + name?: string | null + num_of_employees?: number | null + slug?: string | null + type?: 'technology' | 'expert' | null + website?: string | null + } + Update: { + country?: string | null + description?: string | null + id?: string | null + logo?: never + name?: string | null + num_of_employees?: number | null + slug?: string | null + type?: 'technology' | 'expert' | null + website?: string | null + } + Relationships: [] + } + } + Functions: { + get_redirect_url: { + Args: { + p_listing_id: string + p_organization_slug: string + p_project_id: string + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const