Files
supabase/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx
T
Matt Linkous 171ca026b5 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 -->
2026-06-22 20:51:14 +00:00

218 lines
9.0 KiB
TypeScript

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.',
}}
/>
</>
)
}