mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
bd18ff98e9
commit
171ca026b5
11 files changed
+1157
-9
No files matched your search
+89
@@ -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>
|
||||
)
|
||||
}
|
||||
+320
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
+217
@@ -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.',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+57
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+147
@@ -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's API.</>
|
||||
) : (
|
||||
<>A secret API key that {name} uses to authenticate to your project'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's Edge Functions at runtime.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
A secret synced by {name} and exposed to your project'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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user