Skip to content

Commit 0cb71a2

Browse files
imorjoshenlim
andauthored
feat: new marketplace db (supabase#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 supabase/platform#31298 is merged and available in staging. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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 <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
1 parent 1cb548b commit 0cb71a2

15 files changed

Lines changed: 651 additions & 553 deletions

File tree

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,43 @@
11
import { useState } from 'react'
2-
import { cn } from 'ui'
2+
import { cn, Dialog, DialogContent } from 'ui'
33

44
export const FilesViewer = ({ files }: { files: string[] }) => {
55
const [selected, setSelected] = useState(files[0])
6+
const [showDialog, setShowDialog] = useState(false)
67

78
return (
8-
<div className="flex flex-col gap-y-4">
9-
<img alt={selected} src={selected} className="rounded-md border" />
10-
{files.length > 1 && (
11-
<div className="grid grid-cols-10 gap-x-2">
12-
{files.map((x) => (
13-
<button key={x} onClick={() => setSelected(x)}>
14-
<img
15-
alt={x}
16-
src={x}
17-
className={cn(
18-
'col-span-1 bg-surface-100 rounded-md object-cover aspect-square border transition',
19-
selected === x ? 'border-stronger' : 'border-secondary'
20-
)}
21-
/>
22-
</button>
23-
))}
24-
</div>
25-
)}
26-
</div>
9+
<>
10+
<div className="flex flex-col gap-y-4">
11+
<button onClick={() => setShowDialog(true)}>
12+
<img
13+
alt={selected}
14+
src={selected}
15+
className="rounded-md border object-cover aspect-video"
16+
/>
17+
</button>
18+
19+
{files.length > 1 && (
20+
<div className="grid grid-cols-10 gap-x-2">
21+
{files.map((x) => (
22+
<button key={x} onClick={() => setSelected(x)}>
23+
<img
24+
alt={x}
25+
src={x}
26+
className={cn(
27+
'col-span-1 bg-surface-100 rounded-md object-cover aspect-square border transition',
28+
selected === x ? 'border-button-hover' : 'border-secondary'
29+
)}
30+
/>
31+
</button>
32+
))}
33+
</div>
34+
)}
35+
</div>
36+
<Dialog open={showDialog} onOpenChange={setShowDialog}>
37+
<DialogContent size="xxlarge">
38+
<img alt={selected} src={selected} className="rounded-md border" />
39+
</DialogContent>
40+
</Dialog>
41+
</>
2742
)
2843
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useParams } from 'common'
2+
import { useMemo } from 'react'
3+
import { toast } from 'sonner'
4+
import { Button } from 'ui'
5+
6+
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
7+
import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
8+
import { useInstallOAuthIntegrationMutation } from '@/data/marketplace/install-oauth-integration-mutation'
9+
10+
interface InstallOAuthIntegrationButtonProps {
11+
integration: IntegrationDefinition
12+
}
13+
14+
export function InstallOAuthIntegrationButton({ integration }: InstallOAuthIntegrationButtonProps) {
15+
const { ref: projectRef } = useParams()
16+
17+
const { data: apiKeys, isLoading: isApiKeysLoading } = useAPIKeysQuery(
18+
{ projectRef, reveal: false },
19+
{ enabled: !!projectRef }
20+
)
21+
22+
const { mutate: installOAuthIntegration, isPending: isInstalling } =
23+
useInstallOAuthIntegrationMutation({
24+
onSuccess: (data) => {
25+
if ('redirectUrl' in data) {
26+
if (!data.redirectUrl) {
27+
toast.error('Failed to redirect because redirect URL is invalid')
28+
return
29+
}
30+
window.location.href = data.redirectUrl
31+
} else {
32+
toast.error('Failed to start integration installation')
33+
}
34+
},
35+
})
36+
37+
const isLoading =
38+
integration.installIdentificationMethod === 'secret_key_prefix' && isApiKeysLoading
39+
40+
const isIntegrationInstalled = useMemo(() => {
41+
if (!integration) return false
42+
43+
const prefix = integration.secretKeyPrefix
44+
45+
if (integration.installIdentificationMethod !== 'secret_key_prefix' || !prefix) return false
46+
if (isApiKeysLoading || !apiKeys) return false
47+
48+
return apiKeys.some((k) => k.type === 'secret' && k.name.startsWith(prefix))
49+
}, [apiKeys, integration, isApiKeysLoading])
50+
51+
const handleInstallClick = async () => {
52+
if (!integration || !projectRef) return
53+
54+
if (integration.installUrlType === 'post') {
55+
if (!integration.listingId) return toast.error('Listing ID is required')
56+
installOAuthIntegration({ projectRef, id: integration.listingId })
57+
} else {
58+
window.location.href = integration.installUrl ?? '/'
59+
}
60+
}
61+
62+
return (
63+
<>
64+
{isIntegrationInstalled ? (
65+
<Button disabled type="outline" className="shrink-0">
66+
Installed
67+
</Button>
68+
) : (
69+
<Button
70+
type="primary"
71+
className="shrink-0"
72+
loading={isInstalling || isLoading}
73+
disabled={isLoading}
74+
onClick={handleInstallClick}
75+
>
76+
Install integration
77+
</Button>
78+
)}
79+
</>
80+
)
81+
}

apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ type IntegrationStep = {
4646
description?: string
4747
}
4848

49+
type InstallUrlType = 'get' | 'post'
50+
51+
type InstallIdentificationMethod = 'secret_key_prefix'
52+
4953
/**
5054
* [Joshen] For marketplace, we probably need to revisit this definition
5155
* What properties are obsolete, what properties we need from remote source
@@ -97,6 +101,13 @@ export type IntegrationDefinition = {
97101
inputs?: IntegrationInputs
98102
/** Purely visual, just to show what are the changes on the project from installing the integration */
99103
steps?: IntegrationStep[]
104+
105+
/** These are for OAuth Integrations */
106+
installUrl?: string | null
107+
installUrlType?: InstallUrlType
108+
installIdentificationMethod?: InstallIdentificationMethod
109+
secretKeyPrefix?: string
110+
listingId?: string
100111
} & (
101112
| { type: 'wrapper'; meta: WrapperMeta }
102113
| { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' }

apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx

Lines changed: 96 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
22
import { FeatureFlagContext, IS_PLATFORM, useFlag } from 'common'
33
import { Boxes } from 'lucide-react'
44
import dynamic from 'next/dynamic'
5+
import Image from 'next/image'
56
import { useContext, useMemo } from 'react'
67
import { cn } from 'ui'
78

@@ -10,6 +11,11 @@ import { marketplaceIntegrationsQueryOptions } from '@/data/marketplace/integrat
1011
import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query'
1112
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
1213

14+
const fullImageUrl = (imagePath: string) => {
15+
const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || ''
16+
return `${API_URL}${imagePath}`
17+
}
18+
1319
/**
1420
* [Joshen] Returns a combination of
1521
* - Marketplace integrations retrieved remotely (Only if feature flag enabled)
@@ -33,69 +39,93 @@ export const useAvailableIntegrations = () => {
3339

3440
// [Joshen] Format marketplace integrations into existing ones for now
3541
// Likely that we might need to change, but can look into separately
36-
const marketplaceIntegrations: IntegrationDefinition[] = (data ?? [])?.map((integration) => {
37-
const {
38-
id,
39-
type,
40-
categories,
41-
title: name,
42-
summary: description,
43-
documentation_url: docsUrl,
44-
url: siteUrl,
45-
content,
46-
files,
47-
} = integration
42+
const marketplaceIntegrations: IntegrationDefinition[] = useMemo(
43+
() =>
44+
(data ?? [])?.map((integration) => {
45+
const {
46+
id: listingId,
47+
slug,
48+
categories,
49+
title,
50+
description,
51+
documentation_url: docsUrl,
52+
website_url: siteUrl,
53+
installation_url: installUrl,
54+
installation_url_type: installUrlType,
55+
installation_identification_method: installMethod,
56+
secret_key_prefix: secretKeyPrefix,
57+
images,
58+
content,
59+
partner_name: authorName,
60+
listing_logo: listingLogo,
61+
} = integration
4862

49-
const status = undefined
50-
const author = { name: '', websiteUrl: '' }
63+
const status = undefined
64+
const author = { name: authorName ?? '', websiteUrl: '' }
5165

52-
return {
53-
id: id.toString(),
54-
name,
55-
status,
56-
type,
57-
categories: categories.map((x) => x.slug),
58-
content,
59-
files,
60-
description,
61-
docsUrl,
62-
siteUrl,
63-
author,
64-
requiredExtensions: [],
65-
icon: ({ className, ...props } = {}) => (
66-
<Boxes className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
67-
),
68-
navigation: [
69-
{
70-
route: 'overview',
71-
label: 'Overview',
72-
},
73-
],
74-
navigate: ({ pageId = 'overview' }) => {
75-
switch (pageId) {
76-
case 'overview':
77-
return dynamic(
78-
() =>
79-
import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then(
80-
(mod) => mod.IntegrationOverviewTabV2
81-
),
82-
{
83-
loading: Loading,
84-
}
85-
)
86-
case 'secrets':
87-
return dynamic(
88-
() =>
89-
import('../Vault/Secrets/SecretsManagement').then((mod) => mod.SecretsManagement),
90-
{
91-
loading: Loading,
92-
}
93-
)
66+
return {
67+
id: slug ?? '',
68+
name: title ?? '',
69+
status,
70+
type: 'oauth' as const, // Currently marketplace only supports oauth apps
71+
categories: Array.isArray(categories)
72+
? (categories as Array<{ slug: string }>).map((x) => x.slug)
73+
: [],
74+
content,
75+
files: images?.map((image) => fullImageUrl(image)),
76+
description,
77+
docsUrl,
78+
siteUrl,
79+
installUrl,
80+
installUrlType: installUrlType ?? undefined,
81+
installIdentificationMethod: installMethod ?? undefined,
82+
secretKeyPrefix: secretKeyPrefix ?? undefined,
83+
listingId: listingId ?? undefined,
84+
author,
85+
requiredExtensions: [],
86+
icon: ({ className, ...props } = {}) => (
87+
<div className="relative w-full h-full">
88+
{listingLogo ? (
89+
<Image
90+
fill
91+
src={fullImageUrl(listingLogo)}
92+
alt=""
93+
className={cn('p-2', className)}
94+
{...props}
95+
/>
96+
) : (
97+
<Boxes
98+
className={cn('inset-0 p-2 text-black w-full h-full', className)}
99+
{...props}
100+
/>
101+
)}
102+
</div>
103+
),
104+
navigation: [
105+
{
106+
route: 'overview',
107+
label: 'Overview',
108+
},
109+
],
110+
navigate: ({ pageId = 'overview' }) => {
111+
switch (pageId) {
112+
case 'overview':
113+
return dynamic(
114+
() =>
115+
import('@/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/index').then(
116+
(mod) => mod.IntegrationOverviewTabV2
117+
),
118+
{
119+
loading: Loading,
120+
}
121+
)
122+
}
123+
return null
124+
},
94125
}
95-
return null
96-
},
97-
}
98-
})
126+
}),
127+
[data]
128+
)
99129

100130
// [Joshen] Existing integrations that are defined within studio
101131
// Available integrations are all integrations that can be installed. If an integration can't be installed (needed
@@ -117,13 +147,14 @@ export const useAvailableIntegrations = () => {
117147
})
118148
}, [integrationsWrappers, isCLI])
119149

120-
const availableIntegrations = useMemo(
121-
() => allIntegrations.sort((a, b) => a.name.localeCompare(b.name)),
122-
[allIntegrations]
123-
)
150+
const dataWithMarketplace = useMemo(() => {
151+
return [...marketplaceIntegrations, ...allIntegrations].sort((a, b) =>
152+
a.name.localeCompare(b.name)
153+
)
154+
}, [marketplaceIntegrations, allIntegrations])
124155

125156
return {
126-
data: [...marketplaceIntegrations, ...availableIntegrations],
157+
data: dataWithMarketplace,
127158
error,
128159
isPending,
129160
isSuccess,

apps/studio/components/layouts/ProjectIntegrationsLayout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ const IntegrationCategoriesMenu = ({ page }: { page: string }) => {
8585
items: [],
8686
},
8787
...categories.map((category) => ({
88-
name: category.title,
89-
key: category.slug,
88+
name: category.name ?? '',
89+
key: category.slug ?? '',
9090
url: `/project/${ref}/integrations?category=${category.slug}`,
9191
items: [],
9292
})),

0 commit comments

Comments
 (0)