From 171ca026b593c28f3a2e227e97ed171de9a67771 Mon Sep 17 00:00:00 2001 From: Matt Linkous Date: Mon, 22 Jun 2026 15:51:14 -0500 Subject: [PATCH] feat(studio): Add integration settings page with connected resources (#46961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds integrations settings page to each oauth integration to show associated resources (e.g. API keys, config, oauth apps, etc) Screenshot 2026-06-16 at 2 44 31 PM ## 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. --- .../ConnectedResourceGroupSection.tsx | 89 +++++ ...MarketplaceIntegrationSettingsTab.test.tsx | 320 ++++++++++++++++++ .../MarketplaceIntegrationSettingsTab.tsx | 217 ++++++++++++ ...MarketplaceIntegrationSettingsTab.types.ts | 30 ++ ...tplaceIntegrationSettingsTab.utils.test.ts | 57 ++++ ...arketplaceIntegrationSettingsTab.utils.tsx | 147 ++++++++ .../useConnectedResourceMutations.ts | 55 +++ .../Integrations/Landing/Landing.utils.ts | 215 +++++++++++- .../Landing/useAvailableIntegrations.tsx | 15 + .../Organization/OAuthApps/RevokeAppModal.tsx | 15 +- .../oauth/authorized-app-revoke-mutation.ts | 6 +- 11 files changed, 1157 insertions(+), 9 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.types.ts create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.test.ts create mode 100644 apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Integration/useConnectedResourceMutations.ts diff --git a/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx new file mode 100644 index 00000000000..1b70579d7e6 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/ConnectedResourceGroupSection.tsx @@ -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 ( +
+
+
+

{group.title}

+ {group.missing ? ( + + + Not connected + + ) : ( + group.badge && ( + + + {group.badge} + + ) + )} +
+

{group.description}

+
+ + + {group.missing ? group.missingNote : group.note} + + + {group.missing ? ( + group.manageAction && ( +
+ +
+ ) + ) : ( +
+ {group.items.map((item) => ( +
+
+ + {item.identifier} + + {item.meta && {item.meta}} +
+
+ {group.manageAction && ( + + )} + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx new file mode 100644 index 00000000000..ba0721575c0 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.test.tsx @@ -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>() + return { ...actual, IS_PLATFORM: true } +}) + +// The settings tab renders Radix-based modals (which rely on Web Animations). +mockAnimationsApi() + +const setIntegration = (overrides: Partial = {}) => { + 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(apiKeys), + }) + addAPIMock({ + method: 'get', + path: '/v1/projects/:ref/secrets', + response: () => HttpResponse.json(secrets), + }) + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: () => + HttpResponse.json({ + SMTP_HOST: smtpHost, + } as unknown as AuthConfigResponse), + }) + addAPIMock({ + method: 'get', + path: '/platform/integrations/partners/:ref', + response: () => HttpResponse.json({ integrations: [] }), + }) + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/oauth/apps', + response: () => HttpResponse.json(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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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(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() + + 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()) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx new file mode 100644 index 00000000000..3937defb9dc --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.tsx @@ -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() + const [appToRevoke, setAppToRevoke] = useState() + const [apiKeyToDelete, setApiKeyToDelete] = useState() + + 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 ( + <> + +
+
+

+ Connected resources +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isError ? ( + + ) : groups.length === 0 ? ( + + ) : ( + <> + {showOrphanedResourcesWarning && ( + + )} + {groups.map((group) => ( + + ))} + + )} +
+
+
+ + setConfirmTarget(undefined)} + onConfirm={onConfirmRemove} + > +

+ This removes the resource immediately and may stop {integrationName} from working + correctly. This action cannot be undone. +

+
+ + setAppToRevoke(undefined)} + /> + + 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.', + }} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.types.ts b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.types.ts new file mode 100644 index 00000000000..de7e2e04870 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.types.ts @@ -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 + +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 +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.test.ts b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.test.ts new file mode 100644 index 00000000000..e447c844539 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.test.ts @@ -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) + } + }) +}) diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.tsx new file mode 100644 index 00000000000..185c7c4cc65 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarketplaceIntegrationSettingsTab.utils.tsx @@ -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 => { + const name = {integrationName} + 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, + } + } +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/useConnectedResourceMutations.ts b/apps/studio/components/interfaces/Integrations/Integration/useConnectedResourceMutations.ts new file mode 100644 index 00000000000..36751ac1525 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/useConnectedResourceMutations.ts @@ -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, + } +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts index 873c9a3536e..9600538de58 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts +++ b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts @@ -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> +} + +/** + * 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 = { + 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)) } diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index 5319d27c7cb..bd45de8960e 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -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 }, diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx index bdf097b0d95..28f1db1e41c 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/RevokeAppModal.tsx @@ -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 ( diff --git a/apps/studio/data/oauth/authorized-app-revoke-mutation.ts b/apps/studio/data/oauth/authorized-app-revoke-mutation.ts index 81bf5771417..854a8a8d963 100644 --- a/apps/studio/data/oauth/authorized-app-revoke-mutation.ts +++ b/apps/studio/data/oauth/authorized-app-revoke-mutation.ts @@ -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({ 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) },