feat(studio): Add integration settings page with connected resources (#46961)

Adds integrations settings page to each oauth integration to show
associated resources (e.g. API keys, config, oauth apps, etc)

<img width="1150" height="892" alt="Screenshot 2026-06-16 at 2 44 31 PM"
src="https://github.com/user-attachments/assets/035cc602-886d-43bc-a5a7-e14f76dd37c3"
/>



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary

* **New Features**
* Added a Marketplace “Settings” tab with a grouped **Connected
resources** view (OAuth apps, API keys, Edge Function secrets, SMTP),
including loading/empty/missing-resource states and per-kind removal
actions.
* Added a resource-group section UI plus integration-aware grouping/copy
customization and missing-kind zero-states.
* **Bug Fixes**
* Improved installed-state detection for Grafana and Doppler by
broadening conditions.
* Added an orphaned-resources warning when expected OAuth apps are
missing.
* **Refactor**
* Unified connected-resource removal into a single flow with
OAuth-specific revoke handling.
* **Tests**
* Added comprehensive UI and utility coverage for grouping, states, and
destructive removal behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Matt Linkous authored and GitHub committed 2026-06-22 20:51:14 +00:00
1 parent bd18ff98e9
commit 171ca026b5
11 files changed
+1157 -9

No files matched your search

@@ -0,0 +1,89 @@
import { Settings, Trash2, TriangleAlert } from 'lucide-react'
import { Badge, Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { type ResourceGroup } from './MarketplaceIntegrationSettingsTab.types'
import { type ConnectedResource } from '@/components/interfaces/Integrations/Landing/Landing.utils'
export const ResourceGroupSection = ({
group,
onRemove,
}: {
group: ResourceGroup
onRemove: (resource: ConnectedResource) => void
}) => {
return (
<section className="flex flex-col gap-y-4 border-b py-8 first:pt-0 last:border-b-0">
<div className="flex flex-col gap-y-2">
<div className="flex items-center gap-x-2">
<h3 className="text-base text-foreground">{group.title}</h3>
{group.missing ? (
<Badge variant="warning" className="gap-x-1.5">
<TriangleAlert size={12} strokeWidth={1.5} />
Not connected
</Badge>
) : (
group.badge && (
<Badge variant="success" className="gap-x-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-brand" />
{group.badge}
</Badge>
)
)}
</div>
<p className="text-sm text-foreground-light max-w-2xl">{group.description}</p>
</div>
<Admonition
type={group.missing ? 'warning' : 'default'}
className="m-0 max-w-2xl"
title={group.missing ? 'This resource is missing' : undefined}
>
{group.missing ? group.missingNote : group.note}
</Admonition>
{group.missing ? (
group.manageAction && (
<div className="max-w-2xl">
<Button asChild variant="default" icon={<Settings />}>
<a href={group.manageAction.href}>{group.manageAction.label}</a>
</Button>
</div>
)
) : (
<div className="max-w-2xl divide-y rounded-md border bg-surface-100">
{group.items.map((item) => (
<div
key={item.resource.key}
className="flex items-center justify-between gap-x-4 px-4 py-3"
>
<div className="flex min-w-0 flex-col">
<code
className="truncate font-mono text-sm text-foreground"
title={item.identifier}
>
{item.identifier}
</code>
{item.meta && <span className="text-xs text-foreground-lighter">{item.meta}</span>}
</div>
<div className="flex shrink-0 items-center gap-x-2">
{group.manageAction && (
<Button asChild variant="default" icon={<Settings />}>
<a href={group.manageAction.href}>{group.manageAction.label}</a>
</Button>
)}
<Button
variant="default"
icon={<Trash2 className="text-foreground-light" />}
onClick={() => onRemove(item.resource)}
>
Remove
</Button>
</div>
</div>
))}
</div>
)}
</section>
)
}
@@ -0,0 +1,320 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { describe, expect, test, vi } from 'vitest'
import { MarketplaceIntegrationSettingsTab } from './MarketplaceIntegrationSettingsTab'
import { type IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { type components } from '@/data/api'
import { type APIKey } from '@/data/api-keys/api-keys-query'
import { type AuthConfigResponse } from '@/data/auth/auth-config-query'
import { type ProjectSecret } from '@/data/secrets/secrets-query'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
// The OAuth-apps query overrides its return type to `AuthorizedApp`, but the wire response (and so
// the MSW resolver) is the raw OpenAPI `OAuthAppResponse`. Build fixtures against the API shape.
type OAuthAppResponse = components['schemas']['OAuthAppResponse']
type PartnerIntegrationListResponse = components['schemas']['PartnerIntegrationListResponse']
// `useIntegrationDetail` resolves the integration definition from the route, the marketplace query
// and feature flags — none of which is the subject of this test. Mock it so each test can drive a
// specific integration definition directly. `useSelectedOrganizationQuery` is a composite selector
// over the org list + slug param; mock it to a fixed org so the authorized-apps query can fire.
const { detail } = vi.hoisted(() => ({
detail: { ref: 'default' as string | undefined, integration: undefined as unknown },
}))
vi.mock('@/components/interfaces/Integrations/Landing/useIntegrationDetail', () => ({
useIntegrationDetail: () => detail,
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: { slug: 'acme' } }),
}))
// The auth-config query (used to detect custom SMTP) only runs on the platform.
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return { ...actual, IS_PLATFORM: true }
})
// The settings tab renders Radix-based modals (which rely on Web Animations).
mockAnimationsApi()
const setIntegration = (overrides: Partial<IntegrationDefinition> = {}) => {
detail.integration = {
id: 'custom',
name: 'Custom Integration',
type: 'oauth',
...overrides,
} as IntegrationDefinition
}
const secretApiKey = (name: string): APIKey =>
({
id: `key-${name}`,
name,
type: 'secret',
prefix: 'sb_secret_',
hash: 'hash',
api_key: 'sb_secret_value',
inserted_at: '2026-01-01T00:00:00Z',
secret_jwt_template: { role: 'service_role' },
}) as APIKey
const edgeSecret = (name: string): ProjectSecret =>
({ name, value: 'value', updated_at: '2026-01-01T00:00:00Z' }) as ProjectSecret
const authorizedApp = (appId: string): OAuthAppResponse => ({
id: `app-${appId}`,
app_id: appId,
name: 'Test OAuth App',
website: 'https://example.com',
created_by: 'tester',
authorized_at: '2026-01-01T00:00:00Z',
registration_type: 'manual',
})
/**
* Registers the five endpoints behind `useProjectOAuthIntegrationData`. Each test supplies only the
* resources relevant to it; everything else defaults to "nothing connected".
*/
const mockProjectResources = ({
apiKeys = [] as APIKey[],
secrets = [] as ProjectSecret[],
oauthApps = [] as OAuthAppResponse[],
smtpHost = null as string | null,
} = {}) => {
addAPIMock({
method: 'get',
path: '/v1/projects/:ref/api-keys',
response: () => HttpResponse.json<APIKey[]>(apiKeys),
})
addAPIMock({
method: 'get',
path: '/v1/projects/:ref/secrets',
response: () => HttpResponse.json<ProjectSecret[]>(secrets),
})
addAPIMock({
method: 'get',
path: '/platform/auth/:ref/config',
response: () =>
HttpResponse.json<AuthConfigResponse>({
SMTP_HOST: smtpHost,
} as unknown as AuthConfigResponse),
})
addAPIMock({
method: 'get',
path: '/platform/integrations/partners/:ref',
response: () => HttpResponse.json<PartnerIntegrationListResponse>({ integrations: [] }),
})
addAPIMock({
method: 'get',
path: '/platform/organizations/:slug/oauth/apps',
response: () => HttpResponse.json<OAuthAppResponse[]>(oauthApps),
})
}
describe('MarketplaceIntegrationSettingsTab', () => {
describe('section visibility follows the integration definition', () => {
test('renders the secret API key section when the integration declares a secret key prefix', async () => {
setIntegration({ secretKeyPrefix: 'custom_' })
mockProjectResources({ apiKeys: [secretApiKey('custom_metrics_key')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByRole('heading', { name: 'Secret API key' })).toBeInTheDocument()
expect(screen.getByText('custom_metrics_key')).toBeInTheDocument()
})
test('does not render a secret API key section when the integration declares no prefix, even if secret keys exist', async () => {
setIntegration()
mockProjectResources({ apiKeys: [secretApiKey('custom_metrics_key')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByText('No connected resources')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Secret API key' })).not.toBeInTheDocument()
expect(screen.queryByText('custom_metrics_key')).not.toBeInTheDocument()
})
test('renders the Edge Function secret section only for integrations that declare a secret name', async () => {
setIntegration({ id: 'doppler', name: 'Doppler' })
mockProjectResources({ secrets: [edgeSecret('DOPPLER_CONFIG'), edgeSecret('UNRELATED')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(
await screen.findByRole('heading', { name: 'Edge Function secret' })
).toBeInTheDocument()
expect(screen.getByText('DOPPLER_CONFIG')).toBeInTheDocument()
// Only the integration's own secret is shown, not unrelated project secrets.
expect(screen.queryByText('UNRELATED')).not.toBeInTheDocument()
})
test('renders the SMTP section only for SMTP-configured integrations', async () => {
setIntegration({ id: 'resend', name: 'Resend' })
mockProjectResources({ smtpHost: 'smtp.resend.com' })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByRole('heading', { name: 'SMTP settings' })).toBeInTheDocument()
expect(screen.getByText('smtp.resend.com')).toBeInTheDocument()
})
test('does not render an SMTP section for non-SMTP integrations even when the project uses the Resend host', async () => {
setIntegration({ secretKeyPrefix: 'custom_' })
mockProjectResources({
apiKeys: [secretApiKey('custom_key')],
smtpHost: 'smtp.resend.com',
})
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByRole('heading', { name: 'Secret API key' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'SMTP settings' })).not.toBeInTheDocument()
})
test('shows the empty state when the integration provisions nothing', async () => {
setIntegration()
mockProjectResources()
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByText('No connected resources')).toBeInTheDocument()
expect(
screen.getByText("Custom Integration doesn't have any resources connected to your project.")
).toBeInTheDocument()
})
})
describe('resource section states', () => {
test('a present resource shows the Connected badge and a Remove action', async () => {
setIntegration({ secretKeyPrefix: 'custom_' })
mockProjectResources({ apiKeys: [secretApiKey('custom_key')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByText('custom_key')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
})
test('uses the generic removal warning when the integration has no override copy', async () => {
setIntegration({ secretKeyPrefix: 'custom_' })
mockProjectResources({ apiKeys: [secretApiKey('custom_key')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(
await screen.findByText(
'Removing this key takes effect immediately and can interrupt the integration.'
)
).toBeInTheDocument()
})
test('uses the integration-specific removal warning override when one is defined', async () => {
setIntegration({ id: 'grafana', name: 'Grafana' })
mockProjectResources({ apiKeys: [secretApiKey('grafana_cloud_integration_metrics')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(
await screen.findByText(
'Removing this key stops Grafana from collecting metrics from your project until a new key is connected.'
)
).toBeInTheDocument()
// The generic copy must not also be present.
expect(
screen.queryByText(
'Removing this key takes effect immediately and can interrupt the integration.'
)
).not.toBeInTheDocument()
})
test('an expected-but-absent resource renders a "Not connected" zero state with its absent note', async () => {
// Grafana expects an OAuth app (present here) and an API key (absent), so the API key section
// renders as a missing zero-state rather than being skipped.
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
mockProjectResources({ oauthApps: [authorizedApp('grafana-app')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByText('Not connected')).toBeInTheDocument()
expect(
screen.getByText(
'No secret API key is connected for Grafana to read your project metrics. Dashboards will not receive data without one.'
)
).toBeInTheDocument()
})
test('shows the orphaned-resources warning and hides the OAuth section when the OAuth app is missing', async () => {
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
mockProjectResources({ apiKeys: [secretApiKey('grafana_cloud_integration_metrics')] })
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByText('OAuth application is missing')).toBeInTheDocument()
// The OAuth app's own section is suppressed in favor of the banner, so its description is absent.
expect(
screen.queryByText(
'Grants Grafana access to your organization so it can discover projects to monitor.'
)
).not.toBeInTheDocument()
})
test('renders the OAuth section with a Connected badge when the OAuth app is present', async () => {
setIntegration({ id: 'grafana', name: 'Grafana', oauthAppId: 'grafana-app' })
mockProjectResources({
oauthApps: [authorizedApp('grafana-app')],
apiKeys: [secretApiKey('grafana_cloud_integration_metrics')],
})
customRender(<MarketplaceIntegrationSettingsTab />)
expect(await screen.findByRole('heading', { name: 'OAuth application' })).toBeInTheDocument()
expect(screen.getByText('Connected')).toBeInTheDocument()
expect(
screen.getByText(
'Grants Grafana access to your organization so it can discover projects to monitor.'
)
).toBeInTheDocument()
})
})
describe('removing a resource', () => {
test('deletes an Edge Function secret through the API and clears the section', async () => {
setIntegration({ id: 'doppler', name: 'Doppler' })
// Baseline: nothing else connected. The secrets endpoints below are registered afterwards so
// MSW gives them priority, backing them with a stateful store the refetch can observe.
mockProjectResources()
let secrets: ProjectSecret[] = [edgeSecret('DOPPLER_CONFIG')]
const deleteRequests: unknown[] = []
addAPIMock({
method: 'get',
path: '/v1/projects/:ref/secrets',
response: () => HttpResponse.json<ProjectSecret[]>(secrets),
})
addAPIMock({
method: 'delete',
path: '/v1/projects/:ref/secrets',
response: async ({ request }) => {
deleteRequests.push(await request.json())
secrets = []
// The bulk-delete endpoint replies 200 with no content.
return HttpResponse.json([] as never)
},
})
customRender(<MarketplaceIntegrationSettingsTab />)
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
// Confirm in the destructive ConfirmationModal.
fireEvent.click(await screen.findByRole('button', { name: 'Remove' }))
await waitFor(() => expect(deleteRequests).toEqual([['DOPPLER_CONFIG']]))
await waitFor(() => expect(screen.getByText('No connected resources')).toBeInTheDocument())
})
})
})
@@ -0,0 +1,217 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ResourceGroupSection } from './ConnectedResourceGroupSection'
import { type ApiKeyResource, type ResourceGroup } from './MarketplaceIntegrationSettingsTab.types'
import {
getGroupContent,
getItemIdentifier,
getItemMeta,
KIND_ORDER,
} from './MarketplaceIntegrationSettingsTab.utils'
import { useConnectedResourceMutations } from './useConnectedResourceMutations'
import { ConstrainedIntegrationTabScaffold } from '@/components/interfaces/Integrations/ConstrainedIntegrationTabScaffold'
import {
getConnectedResources,
getConnectedResourceUsage,
getExpectedResourceKinds,
useProjectOAuthIntegrationData,
type ConnectedResource,
} from '@/components/interfaces/Integrations/Landing/Landing.utils'
import { useIntegrationDetail } from '@/components/interfaces/Integrations/Landing/useIntegrationDetail'
import { RevokeAppModal } from '@/components/interfaces/Organization/OAuthApps/RevokeAppModal'
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
import type { AuthorizedApp } from '@/data/oauth/authorized-apps-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
export const MarketplaceIntegrationSettingsTab = () => {
const { ref, integration } = useIntegrationDetail()
const { data: organization } = useSelectedOrganizationQuery()
const { data: projectData, isLoading, isError, error } = useProjectOAuthIntegrationData(ref)
// The generic confirmation modal targets a single resource. The OAuth app and API keys are
// removed through their own dedicated modals, tracked separately.
const [confirmTarget, setConfirmTarget] = useState<ConnectedResource | undefined>()
const [appToRevoke, setAppToRevoke] = useState<AuthorizedApp | undefined>()
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeyResource | undefined>()
const { removeResource, isRemoving } = useConnectedResourceMutations({
projectRef: ref,
orgSlug: organization?.slug,
onSuccess: () => toast.success('Successfully removed the connected resource'),
})
const integrationName = integration?.name ?? 'this integration'
const resources =
integration && projectData ? getConnectedResources({ integration, projectData }) : []
// The kinds of resources this integration is expected to provision. Used to render a zero
// (missing) state for any expected resource that isn't currently present.
const expectedKinds = integration ? getExpectedResourceKinds(integration) : []
// Only surface missing zero-states when the integration is otherwise still connected (at least
// one resource present). A fully uninstalled integration falls through to the empty state below.
const hasAnyResource = resources.length > 0
// Group resources by kind so e.g. multiple secret API keys render under one section. Expected
// kinds with no present resources render as a missing zero-state instead of being skipped.
const groups: ResourceGroup[] = KIND_ORDER.flatMap((kind) => {
const kindResources = resources.filter((resource) => resource.kind === kind)
const isExpected = expectedKinds.includes(kind)
const isMissing = kindResources.length === 0
// Skip kinds that are neither present nor expected, and missing kinds while nothing is
// connected. A missing OAuth app is communicated by the top-level orphaned-resources warning
// instead of its own section, so it's skipped here to avoid repeating the same message.
if (isMissing && (!isExpected || !hasAnyResource || kind === 'oauth_app')) return []
return [
{
kind,
...getGroupContent({
kind,
// The zero-state copy reads in the singular, so default the count to 1 when missing.
count: Math.max(kindResources.length, 1),
integrationName,
orgSlug: organization?.slug,
projectRef: ref,
usage: integration ? getConnectedResourceUsage(integration.id, kind) : undefined,
}),
missing: isMissing,
items: kindResources.map((resource) => ({
resource,
identifier: getItemIdentifier(resource),
meta: getItemMeta(resource),
})),
},
]
})
// Generic warning for OAuth-connected integrations: the OAuth app is gone but other resources it
// provisioned are still associated with the project, leaving the integration in a broken state.
const isOAuthAppMissing =
expectedKinds.includes('oauth_app') && !resources.some((r) => r.kind === 'oauth_app')
const hasOtherResources = resources.some((r) => r.kind !== 'oauth_app')
const showOrphanedResourcesWarning = isOAuthAppMissing && hasOtherResources
const onSelectRemove = (resource: ConnectedResource) => {
// Reuse the same dedicated modals used elsewhere for OAuth apps and API keys.
if (resource.kind === 'oauth_app') setAppToRevoke(resource.app)
else if (resource.kind === 'api_key') setApiKeyToDelete(resource)
else setConfirmTarget(resource)
}
const onConfirmRemove = async () => {
if (!confirmTarget) return
try {
await removeResource(confirmTarget)
setConfirmTarget(undefined)
} catch (err) {
toast.error(`Failed to remove: ${err instanceof Error ? err.message : 'Unknown error'}`)
}
}
const onConfirmDeleteApiKey = async () => {
if (!apiKeyToDelete) return
try {
await removeResource(apiKeyToDelete)
setApiKeyToDelete(undefined)
} catch {
// Error toast is handled by the mutation's default onError.
}
}
const confirmResourceTitle = confirmTarget ? confirmTarget.title.toLowerCase() : 'resource'
return (
<>
<ConstrainedIntegrationTabScaffold>
<div className="mx-auto w-full max-w-3xl">
<div className="flex flex-col gap-y-3">
<h2 className="flex items-center gap-x-2 text-2xl text-foreground">
Connected resources
</h2>
</div>
<div className="mt-8">
{isLoading ? (
<div className="pt-8">
<GenericSkeletonLoader />
</div>
) : isError ? (
<Admonition
type="warning"
title="Failed to load connected resources"
description={error?.message}
className="mt-8"
/>
) : groups.length === 0 ? (
<Admonition
type="default"
title="No connected resources"
description={`${integrationName} doesn't have any resources connected to your project.`}
className="mt-8"
/>
) : (
<>
{showOrphanedResourcesWarning && (
<Admonition
type="warning"
title="OAuth application is missing"
description={`No OAuth app is connected for ${integrationName}, but other resources associated with it are still present on your project. ${integrationName} may not work correctly without an OAuth app. Reconnect it, or to fully disconnect the integration, remove the remaining resources below.`}
className="mb-8 mt-0"
/>
)}
{groups.map((group) => (
<ResourceGroupSection key={group.kind} group={group} onRemove={onSelectRemove} />
))}
</>
)}
</div>
</div>
</ConstrainedIntegrationTabScaffold>
<ConfirmationModal
variant="destructive"
visible={confirmTarget !== undefined}
loading={isRemoving}
title={`Remove ${confirmResourceTitle}?`}
confirmLabel="Remove"
confirmLabelLoading="Removing"
onCancel={() => setConfirmTarget(undefined)}
onConfirm={onConfirmRemove}
>
<p className="text-sm text-foreground-light">
This removes the resource immediately and may stop {integrationName} from working
correctly. This action cannot be undone.
</p>
</ConfirmationModal>
<RevokeAppModal
selectedApp={appToRevoke}
orgSlug={organization?.slug}
onClose={() => setAppToRevoke(undefined)}
/>
<TextConfirmModal
variant="destructive"
visible={apiKeyToDelete !== undefined}
loading={isRemoving}
title={`Delete secret API key: ${apiKeyToDelete?.apiKey.name ?? ''}`}
confirmString={apiKeyToDelete?.apiKey.name ?? ''}
confirmLabel="Yes, irreversibly delete this API key"
confirmPlaceholder="Type the name of the API key to confirm"
onCancel={() => setApiKeyToDelete(undefined)}
onConfirm={onConfirmDeleteApiKey}
alert={{
title: 'This cannot be undone',
description:
'Make sure all components using this key have been updated. Deletion will cause them to receive HTTP 401 Unauthorized status codes on all Supabase APIs.',
}}
/>
</>
)
}
@@ -0,0 +1,30 @@
import { type ReactNode } from 'react'
import {
type ConnectedResource,
type ConnectedResourceKind,
} from '@/components/interfaces/Integrations/Landing/Landing.utils'
export type ResourceKind = ConnectedResourceKind
export type ApiKeyResource = Extract<ConnectedResource, { kind: 'api_key' }>
export type ManageAction = { label: string; href: string }
/** A single removable entry within a section (one OAuth app, one API key, one secret, etc.). */
export type ResourceItem = { resource: ConnectedResource; identifier: string; meta?: string }
/** A group of same-kind resources rendered as one section (e.g. all secret API keys together). */
export type ResourceGroup = {
kind: ResourceKind
title: string
badge?: string
description: ReactNode
note: string
/** Impact copy shown in the zero state when the resource is expected but absent. */
missingNote: string
manageAction?: ManageAction
items: ResourceItem[]
/** True when the integration expects this resource but none is currently connected. */
missing?: boolean
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from 'vitest'
import { getGroupContent, KIND_ORDER } from './MarketplaceIntegrationSettingsTab.utils'
import { type ConnectedResourceUsage } from '@/components/interfaces/Integrations/Landing/Landing.utils'
const base = { integrationName: 'Grafana', orgSlug: 'acme', projectRef: 'abcdefghijklmnop' }
describe('getGroupContent', () => {
test('falls back to generic copy when no usage override is provided', () => {
const group = getGroupContent({ kind: 'api_key', count: 1, ...base })
expect(group.note).toContain('Removing this key')
expect(group.missingNote).toContain('No secret API key is connected for Grafana')
})
test('uses integration-specific usage copy when provided', () => {
const usage: ConnectedResourceUsage = {
removalWarning: 'Removing this key stops metrics collection.',
noteWhenAbsent: 'No API key connected. Dashboards will not receive data.',
}
const group = getGroupContent({ kind: 'api_key', count: 1, ...base, usage })
expect(group.note).toBe(usage.removalWarning)
expect(group.missingNote).toBe(usage.noteWhenAbsent)
})
test('pluralizes the title and notes based on count', () => {
const single = getGroupContent({ kind: 'api_key', count: 1, ...base })
const plural = getGroupContent({ kind: 'api_key', count: 3, ...base })
expect(single.title).toBe('Secret API key')
expect(plural.title).toBe('Secret API keys')
expect(plural.note).toContain('a key')
})
test('exposes the OAuth app manage action only when an org slug is present', () => {
expect(getGroupContent({ kind: 'oauth_app', count: 1, ...base }).manageAction).toEqual({
label: 'Manage access',
href: '/org/acme/apps',
})
expect(
getGroupContent({ kind: 'oauth_app', count: 1, integrationName: 'Grafana' }).manageAction
).toBeUndefined()
})
test('points the SMTP manage action at the project auth settings', () => {
const group = getGroupContent({ kind: 'smtp', count: 1, ...base })
expect(group.manageAction).toEqual({
label: 'Manage settings',
href: '/project/abcdefghijklmnop/auth/smtp',
})
})
test('always returns a missing note for every resource kind', () => {
for (const kind of KIND_ORDER) {
const group = getGroupContent({ kind, count: 1, ...base })
expect(group.missingNote.length).toBeGreaterThan(0)
}
})
})
@@ -0,0 +1,147 @@
import { type ResourceGroup, type ResourceKind } from './MarketplaceIntegrationSettingsTab.types'
import {
type ConnectedResource,
type ConnectedResourceUsage,
} from '@/components/interfaces/Integrations/Landing/Landing.utils'
import { formatDate } from '@/lib/datetime'
export const KIND_ORDER: ResourceKind[] = ['oauth_app', 'api_key', 'edge_function_secret', 'smtp']
/** Formats a resource timestamp for display, returning undefined for missing values. */
const formatResourceDate = (value?: string | null) =>
value ? formatDate(value, { format: 'MMM D, YYYY' }) : undefined
/** The monospace identifier shown for an individual resource. */
export const getItemIdentifier = (resource: ConnectedResource): string => {
switch (resource.kind) {
case 'oauth_app':
return resource.app.name
case 'api_key':
return resource.apiKey.name
case 'edge_function_secret':
return resource.secret.name
case 'smtp':
return 'smtp.resend.com'
}
}
/** Secondary metadata (timestamp) shown beneath an individual resource. */
export const getItemMeta = (resource: ConnectedResource): string | undefined => {
switch (resource.kind) {
case 'oauth_app': {
const date = formatResourceDate(resource.app.authorized_at)
return date ? `Authorized ${date}` : undefined
}
case 'api_key': {
const date = formatResourceDate(resource.apiKey.inserted_at)
return date ? `Created ${date}` : undefined
}
case 'edge_function_secret': {
const date = formatResourceDate(resource.secret.updated_at)
return date ? `Updated ${date}` : undefined
}
case 'smtp':
return undefined
}
}
/**
* Section-level, integration-aware copy for a kind of resource. The settings page reads like a
* guide that explains what each resource does and what removing it affects.
*/
export const getGroupContent = ({
kind,
count,
integrationName,
orgSlug,
projectRef,
usage,
}: {
kind: ResourceKind
count: number
integrationName: string
orgSlug?: string
projectRef?: string
/** Integration-specific copy that overrides the generic, kind-level description and note. */
usage?: ConnectedResourceUsage
}): Omit<ResourceGroup, 'kind' | 'items' | 'missing'> => {
const name = <span className="text-foreground">{integrationName}</span>
const plural = count > 1
switch (kind) {
case 'oauth_app':
return {
title: 'OAuth application',
badge: 'Connected',
description: usage?.description ?? (
<>
Grants {name} access to your organization and its projects through a scoped OAuth grant.
</>
),
note:
usage?.removalWarning ??
'Removing this OAuth app will remove it for all projects and members of your organization.',
missingNote:
usage?.noteWhenAbsent ??
`No OAuth app is connected for ${integrationName}. It does not have access to your organization or its projects.`,
manageAction: orgSlug
? { label: 'Manage access', href: `/org/${orgSlug}/apps` }
: undefined,
}
case 'api_key':
return {
title: plural ? 'Secret API keys' : 'Secret API key',
description:
usage?.description ??
(plural ? (
<>Secret API keys that {name} uses to authenticate to your project&apos;s API.</>
) : (
<>A secret API key that {name} uses to authenticate to your project&apos;s API.</>
)),
note:
usage?.removalWarning ??
`Removing ${plural ? 'a key' : 'this key'} takes effect immediately and can interrupt the integration.`,
missingNote:
usage?.noteWhenAbsent ??
`No secret API key is connected for ${integrationName}. It cannot authenticate to your project's API.`,
}
case 'edge_function_secret':
return {
title: plural ? 'Edge Function secrets' : 'Edge Function secret',
description:
usage?.description ??
(plural ? (
<>
Secrets synced by {name} and exposed to your project&apos;s Edge Functions at runtime.
</>
) : (
<>
A secret synced by {name} and exposed to your project&apos;s Edge Functions at
runtime.
</>
)),
note:
usage?.removalWarning ??
`Removing ${plural ? 'a secret' : 'this secret'} takes effect immediately and can interrupt the integration.`,
missingNote:
usage?.noteWhenAbsent ??
`No Edge Function secret from ${integrationName} was detected. Functions that rely on it may fail at runtime.`,
}
case 'smtp':
return {
title: 'SMTP settings',
description: usage?.description ?? (
<>A custom SMTP relay so your project sends emails through {name}.</>
),
note:
usage?.removalWarning ??
'Removing this relay reverts your project to the default email service. Auth emails may be rate-limited until SMTP is configured again.',
missingNote:
usage?.noteWhenAbsent ??
`SMTP settings for ${integrationName} were not detected. Authentication emails may not be sent through the integration's SMTP service.`,
manageAction: projectRef
? { label: 'Manage settings', href: `/project/${projectRef}/auth/smtp` }
: undefined,
}
}
}
@@ -0,0 +1,55 @@
import { defaultDisabledSmtpFormValues } from '@/components/interfaces/Auth/SmtpForm/SmtpForm.constants'
import { type ConnectedResource } from '@/components/interfaces/Integrations/Landing/Landing.utils'
import { useAPIKeyDeleteMutation } from '@/data/api-keys/api-key-delete-mutation'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAuthorizedAppRevokeMutation } from '@/data/oauth/authorized-app-revoke-mutation'
import { useSecretsDeleteMutation } from '@/data/secrets/secrets-delete-mutation'
/**
* Combines the four mutations used to remove a connected resource (OAuth app, secret API key,
* Edge Function secret, custom SMTP) into a single hook. The component only needs to dispatch a
* removal and read an aggregated loading state, rather than wiring up each mutation individually.
*/
export const useConnectedResourceMutations = ({
projectRef,
orgSlug,
onSuccess,
}: {
projectRef?: string
orgSlug?: string
onSuccess?: () => void
}) => {
const { mutateAsync: revokeAuthorizedApp, isPending: isRevokingApp } =
useAuthorizedAppRevokeMutation({ onSuccess })
const { mutateAsync: deleteAPIKey, isPending: isDeletingApiKey } = useAPIKeyDeleteMutation({
onSuccess,
})
const { mutateAsync: deleteSecrets, isPending: isDeletingSecret } = useSecretsDeleteMutation({
onSuccess,
})
const { mutateAsync: updateAuthConfig, isPending: isUpdatingAuthConfig } =
useAuthConfigUpdateMutation({ onSuccess })
/** Dispatches the correct mutation for a given resource based on its kind. */
const removeResource = async (resource: ConnectedResource) => {
switch (resource.kind) {
case 'oauth_app':
if (!orgSlug) throw new Error('Organization is required')
return revokeAuthorizedApp({ orgSlug, id: resource.app.id })
case 'api_key':
if (!projectRef) throw new Error('Project is required')
return deleteAPIKey({ projectRef, id: resource.apiKey.id! })
case 'edge_function_secret':
if (!projectRef) throw new Error('Project is required')
return deleteSecrets({ projectRef, secrets: [resource.secret.name] })
case 'smtp':
if (!projectRef) throw new Error('Project is required')
return updateAuthConfig({ projectRef, config: defaultDisabledSmtpFormValues })
}
}
return {
removeResource,
isRemoving: isRevokingApp || isDeletingApiKey || isDeletingSecret || isUpdatingAuthConfig,
}
}
@@ -142,7 +142,10 @@ export const isOAuthInstalled = ({
if (integration.id === 'grafana') {
// Grafana is not yet sending integration status, so just use presence of API key.
return isSecretKeyPrefixPresent(projectData, 'grafana_cloud_integration_')
return (
isOAuthAppAuthorized(projectData, integration) ||
isSecretKeyPrefixPresent(projectData, 'grafana_cloud_integration_')
)
}
if (integration.id === 'aikido') {
@@ -151,7 +154,8 @@ export const isOAuthInstalled = ({
if (integration.id === 'doppler') {
return (
isEdgeFunctionSecretPresent(projectData, 'DOPPLER_CONFIG') &&
isOAuthAppAuthorized(projectData, integration) ||
isEdgeFunctionSecretPresent(projectData, 'DOPPLER_CONFIG') ||
isPartnerIntegrationReady(projectData, integration)
)
}
@@ -177,6 +181,213 @@ export const isOAuthInstalled = ({
return false
}
/**
* A resource associated with a specific integration provisions that can be managed by the user
*/
export type ConnectedResource =
| { kind: 'oauth_app'; key: string; title: string; description: string; app: AuthorizedApp }
| { kind: 'api_key'; key: string; title: string; description: string; apiKey: APIKey }
| {
kind: 'edge_function_secret'
key: string
title: string
description: string
secret: ProjectSecret
}
| { kind: 'smtp'; key: string; title: string; description: string }
export type ConnectedResourceKind = ConnectedResource['kind']
/**
* Integration-specific copy that explains how a particular partner uses a kind of connected
* resource. When present it replaces the generic, kind-level copy shown on the integration's
* settings tab so users understand what the resource does for that integration specifically
* (e.g. that Grafana uses a secret API key to read project metrics).
*/
export type ConnectedResourceUsage = {
/** Explains how this integration uses the resource. Overrides the generic section description. */
description?: string
/** Describes the impact of removing the resource. Overrides the generic section note. */
removalWarning?: string
/**
* Describes the impact of this resource being missing while the integration is otherwise still
* connected. Shown in the resource's zero (missing) state. Overrides the generic absent note.
*/
noteWhenAbsent?: string
}
type IntegrationResourceOverride = {
secretKeyPrefix?: string
edgeFunctionSecretName?: string
resendSmtp?: boolean
/** Per-resource-kind explanations of how this integration uses each connected resource. */
usage?: Partial<Record<ConnectedResourceKind, ConnectedResourceUsage>>
}
/**
* Temporary manual overrides for specific integrations.
* TODO(integrations-team) to move logic to database
* Complements the special-case logic in {@link isOAuthInstalled}.
*/
const INTEGRATION_RESOURCE_OVERRIDES: Record<string, IntegrationResourceOverride> = {
grafana: {
secretKeyPrefix: 'grafana_cloud_integration_',
usage: {
oauth_app: {
description:
'Grants Grafana access to your organization so it can discover projects to monitor.',
},
api_key: {
description:
'Grafana uses this secret API key to read your project metrics from the Prometheus-compatible metrics endpoint.',
removalWarning:
'Removing this key stops Grafana from collecting metrics from your project until a new key is connected.',
noteWhenAbsent:
'No secret API key is connected for Grafana to read your project metrics. Dashboards will not receive data without one.',
},
},
},
doppler: {
edgeFunctionSecretName: 'DOPPLER_CONFIG',
usage: {
oauth_app: {
description:
'Grants Doppler access to your organization so it can update secrets in your projects.',
noteWhenAbsent:
'Doppler does not have access to update secrets in your project. Any changes you make to secrets in Doppler will not be reflected in your project until access is granted.',
},
edge_function_secret: {
description:
'Doppler syncs your managed secrets into this Edge Function secret so they are available to your functions at runtime.',
removalWarning:
'Connected secrets that are removed while this integration is active may be resynced if still present in Doppler.',
noteWhenAbsent: 'No Edge Function secrets were found connected to this integration.',
},
},
},
resend: {
resendSmtp: true,
usage: {
oauth_app: {
description:
'Grants Resend access to manage the custom SMTP configuration used to send your project emails.',
},
smtp: {
description:
'Resend is configured as the custom SMTP relay your project uses to deliver authentication and transactional emails.',
noteWhenAbsent:
"SMTP settings for Resend were not detected. Authentication emails may not be sent through Resend's SMTP service.",
},
},
},
}
/**
* The connected resource kinds an integration is expected to provision, in display order. Derived
* from the same identifiers used by {@link getConnectedResources} and {@link isOAuthInstalled} so
* the settings tab can render a zero (missing) state for any expected resource that is absent.
*/
export const getExpectedResourceKinds = (
integration: IntegrationDefinition
): ConnectedResourceKind[] => {
const overrides = INTEGRATION_RESOURCE_OVERRIDES[integration.id] ?? {}
const kinds: ConnectedResourceKind[] = []
if (integration.oauthAppId) kinds.push('oauth_app')
if (overrides.secretKeyPrefix ?? integration.secretKeyPrefix) kinds.push('api_key')
if (overrides.edgeFunctionSecretName ?? integration.edgeFunctionSecretName) {
kinds.push('edge_function_secret')
}
if (overrides.resendSmtp) kinds.push('smtp')
return kinds
}
/**
* Returns the integration-specific usage copy for a connected resource kind, if one has been
* defined in {@link INTEGRATION_RESOURCE_OVERRIDES}. Callers fall back to generic, kind-level
* copy when this returns `undefined`.
*/
export const getConnectedResourceUsage = (
integrationId: string,
kind: ConnectedResourceKind
): ConnectedResourceUsage | undefined =>
INTEGRATION_RESOURCE_OVERRIDES[integrationId]?.usage?.[kind]
/**
* Collects every resource an OAuth integration has provisioned on the current project/organization
* so it can be displayed and removed from the integration's settings tab. Keyed off the same data
* and identifiers used by {@link isOAuthInstalled}.
*/
export const getConnectedResources = ({
integration,
projectData,
}: {
integration: IntegrationDefinition
projectData: ProjectOAuthIntegrationData
}): ConnectedResource[] => {
const overrides = INTEGRATION_RESOURCE_OVERRIDES[integration.id] ?? {}
const resources: ConnectedResource[] = []
// OAuth apps
if (integration.oauthAppId) {
const app = projectData.oauthApps.find((a) => a.app_id === integration.oauthAppId)
if (app) {
resources.push({
kind: 'oauth_app',
key: `oauth_app:${app.id}`,
title: 'OAuth application',
description: `Grants ${integration.name} access to your organization and its projects.`,
app,
})
}
}
// Secret API keys
const secretKeyPrefix = overrides.secretKeyPrefix ?? integration.secretKeyPrefix
if (secretKeyPrefix) {
projectData.apiKeys
.filter((key) => key.type === 'secret' && key.name.startsWith(secretKeyPrefix))
.forEach((apiKey) => {
resources.push({
kind: 'api_key',
key: `api_key:${apiKey.id}`,
title: 'Secret API key',
description: apiKey.name,
apiKey,
})
})
}
// Edge Function secrets
const edgeFunctionSecretName =
overrides.edgeFunctionSecretName ?? integration.edgeFunctionSecretName
if (edgeFunctionSecretName) {
const secret = projectData.edgeFunctionSecrets.find((s) => s.name === edgeFunctionSecretName)
if (secret) {
resources.push({
kind: 'edge_function_secret',
key: `edge_function_secret:${secret.name}`,
title: 'Edge Function secret',
description: secret.name,
secret,
})
}
}
// Custom SMTP relay
if (overrides.resendSmtp && projectData.authConfig?.SMTP_HOST === 'smtp.resend.com') {
resources.push({
kind: 'smtp',
key: 'smtp',
title: 'SMTP settings',
description: `Custom SMTP relay configured to send project emails through ${integration.name}.`,
})
}
return resources
}
export const hasMatchingWrapper = ({ meta, wrappers }: { meta: WrapperMeta; wrappers: FDW[] }) => {
return wrappers.find((w) => wrapperMetaComparator(meta, w))
}
@@ -125,6 +125,11 @@ export const useAvailableIntegrations = () => {
route: 'overview',
label: 'Overview',
},
{
route: 'settings',
label: 'Settings',
layout: 'constrained',
},
],
navigate: ({ pageId = 'overview' }) => {
switch (pageId) {
@@ -138,6 +143,16 @@ export const useAvailableIntegrations = () => {
loading: Loading,
}
)
case 'settings':
return dynamic(
() =>
import('@/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab').then(
(mod) => mod.MarketplaceIntegrationSettingsTab
),
{
loading: Loading,
}
)
}
return null
},
@@ -18,11 +18,18 @@ import type { AuthorizedApp } from '@/data/oauth/authorized-apps-query'
export interface RevokeAppModalProps {
selectedApp?: AuthorizedApp
/** Optional Organization slug override for routes without a `slug` param (e.g. project integrations). */
orgSlug?: string
onClose: () => void
}
export const RevokeAppModal = ({ selectedApp, onClose }: RevokeAppModalProps) => {
const { slug } = useParams()
export const RevokeAppModal = ({
selectedApp,
orgSlug: slugOverride,
onClose,
}: RevokeAppModalProps) => {
const { slug: slugParam } = useParams()
const orgSlug = slugOverride ?? slugParam
const { mutateAsync: revokeAuthorizedApp } = useAuthorizedAppRevokeMutation({
onSuccess: () => {
toast.success(`Successfully revoked the app "${selectedApp?.name}"`)
@@ -31,9 +38,9 @@ export const RevokeAppModal = ({ selectedApp, onClose }: RevokeAppModalProps) =>
})
const onConfirmDelete = async () => {
if (!slug) return console.error('Slug is required')
if (!orgSlug) return console.error('Organization slug is required')
if (!selectedApp?.id) return console.error('App ID is required')
await revokeAuthorizedApp({ slug, id: selectedApp?.id })
await revokeAuthorizedApp({ orgSlug, id: selectedApp?.id })
}
return (
@@ -7,10 +7,10 @@ import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type AuthorizedAppRevokeVariables = {
id: string
slug: string
orgSlug: string
}
export async function revokeAuthorizedApp({ id, slug }: AuthorizedAppRevokeVariables) {
export async function revokeAuthorizedApp({ id, orgSlug: slug }: AuthorizedAppRevokeVariables) {
if (!id) throw new Error('App ID is required')
if (!slug) throw new Error('Organization slug is required')
@@ -37,7 +37,7 @@ export const useAuthorizedAppRevokeMutation = ({
return useMutation<AuthorizedAppRevokeData, ResponseError, AuthorizedAppRevokeVariables>({
mutationFn: (vars) => revokeAuthorizedApp(vars),
async onSuccess(data, variables, context) {
const { slug } = variables
const { orgSlug: slug } = variables
await queryClient.invalidateQueries({ queryKey: oauthAppKeys.authorizedApps(slug) })
await onSuccess?.(data, variables, context)
},