Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type SetupIntent,
} from '@stripe/stripe-js'
import { Form } from '@ui/components/shadcn/ui/form'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Check, ChevronsUpDown, HelpCircle } from 'lucide-react'
import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
Expand All @@ -34,6 +34,9 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
Expand Down Expand Up @@ -345,6 +348,19 @@ export const NewPaymentMethodElement = forwardRef(
<label htmlFor="business" className="text-foreground text-sm leading-none">
I’m purchasing as a business
</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="text-foreground-lighter hover:text-foreground transition"
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
Select this only if your business is tax-registered. You’ll be asked for a tax ID
(e.g. US EIN, VAT, GST), which is required to issue a compliant business invoice. If
you’re not tax-registered, leave this unchecked. You’ll still receive a receipt.
</TooltipContent>
</Tooltip>
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const FRAMEWORKS: ConnectionType[] = [
},
{
key: 'remix',
label: 'Remix',
label: 'React Router',
icon: 'remix',
guideLink: `${DOCS_URL}/guides/auth/server-side/creating-a-client?framework=remix&environment=remix-loader`,
children: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function createClient(request: Request) {

const supabase = createServerClient(
process.env.VITE_SUPABASE_URL!,
process.env.VITE_${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_KEY' : 'SUPABASE_ANON_KEY'};,
process.env.VITE_${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_KEY' : 'SUPABASE_ANON_KEY'}!,
{
cookies: {
getAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export const FRAMEWORKS: ConnectionType[] = [
},
{
key: 'remix',
label: 'Remix',
label: 'React Router',
icon: 'remix',
guideLink: `${DOCS_URL}/guides/auth/server-side/creating-a-client?framework=remix&environment=remix-loader`,
children: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function createClient(request: Request) {

const supabase = createServerClient(
process.env.VITE_SUPABASE_URL!,
process.env.VITE_${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_KEY' : 'SUPABASE_ANON_KEY'},
process.env.VITE_${projectKeys.publishableKey ? 'SUPABASE_PUBLISHABLE_KEY' : 'SUPABASE_ANON_KEY'}!,
{
cookies: {
getAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import { useMemo } from 'react'
import { toast } from 'sonner'
import { Button } from 'ui'

import { isOAuthInstalled, useProjectOAuthIntegrationData } from '../../../Landing/Landing.utils'
import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants'
import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
import { useInstallOAuthIntegrationMutation } from '@/data/marketplace/install-oauth-integration-mutation'
import { usePartnerIntegrationsQuery } from '@/data/partners/integration-status-query'
import { useSecretsQuery } from '@/data/secrets/secrets-query'

interface InstallOAuthIntegrationButtonProps {
integration: IntegrationDefinition
Expand All @@ -16,28 +14,7 @@ interface InstallOAuthIntegrationButtonProps {
export function InstallOAuthIntegrationButton({ integration }: InstallOAuthIntegrationButtonProps) {
const { ref: projectRef } = useParams()

const requiresApiKeysCheck =
integration.installIdentificationMethod === 'secret_key_prefix' && !!integration.secretKeyPrefix

const requiresEdgeFunctionSecretsCheck =
integration.installIdentificationMethod === 'edge_function_secret_name' &&
!!integration.edgeFunctionSecretName

const requiresPartnerIntegrationsCheck =
integration.installIdentificationMethod === 'integration_status'

const { data: apiKeys, isLoading: isApiKeysLoading } = useAPIKeysQuery(
{ projectRef, reveal: false },
{ enabled: requiresApiKeysCheck }
)

const { data: edgeFunctionSecrets, isPending: isEdgeFunctionSecretsLoading } = useSecretsQuery(
{ projectRef },
{ enabled: requiresEdgeFunctionSecretsCheck }
)

const { data: partnerIntegrations, isPending: isPartnerIntegrationsLoading } =
usePartnerIntegrationsQuery({ projectRef }, { enabled: requiresPartnerIntegrationsCheck })
const { data, isLoading } = useProjectOAuthIntegrationData(projectRef)

const { mutate: installOAuthIntegration, isPending: isInstalling } =
useInstallOAuthIntegrationMutation({
Expand All @@ -54,43 +31,11 @@ export function InstallOAuthIntegrationButton({ integration }: InstallOAuthInteg
},
})

const isLoading =
(requiresApiKeysCheck && isApiKeysLoading) ||
(requiresEdgeFunctionSecretsCheck && isEdgeFunctionSecretsLoading) ||
(requiresPartnerIntegrationsCheck && isPartnerIntegrationsLoading)

const isIntegrationInstalled = useMemo(() => {
if (!integration) return false

if (integration.installIdentificationMethod === 'secret_key_prefix') {
const prefix = integration.secretKeyPrefix
if (!prefix || isApiKeysLoading || !apiKeys) return false
return apiKeys.some((k) => k.type === 'secret' && k.name.startsWith(prefix))
}

if (integration.installIdentificationMethod === 'edge_function_secret_name') {
const secretName = integration.edgeFunctionSecretName
if (!secretName || isEdgeFunctionSecretsLoading || !edgeFunctionSecrets) return false
return edgeFunctionSecrets.some((secret) => secret.name === secretName)
}

if (integration.installIdentificationMethod === 'integration_status') {
if (isPartnerIntegrationsLoading || !partnerIntegrations) return false
return partnerIntegrations.some(
(i) => i.listing_slug === integration.id && i.status === 'ready'
)
}

return false
}, [
apiKeys,
edgeFunctionSecrets,
partnerIntegrations,
integration,
isApiKeysLoading,
isEdgeFunctionSecretsLoading,
isPartnerIntegrationsLoading,
])
return isOAuthInstalled({ integration, projectData: data })
}, [data, integration])

const handleInstallClick = async () => {
if (!integration || !projectRef) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export type IntegrationDefinition = {
secretKeyPrefix?: string
edgeFunctionSecretName?: string
listingId?: string
oauthAppId?: string
} & (
| { type: 'wrapper'; meta: WrapperMeta }
| { type: 'postgres_extension' | 'custom' | 'oauth' | 'template' }
Expand Down
163 changes: 143 additions & 20 deletions apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { parseSchemaComment } from 'stripe-experiment-sync/supabase'

import { type WrapperMeta } from '../Wrappers/Wrappers.types'
Expand All @@ -7,48 +8,170 @@ import {
isInstalled as checkIsInstalled,
findStripeSchema,
} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status'
import { type APIKey } from '@/data/api-keys/api-keys-query'
import { useAPIKeysQuery, type APIKey } from '@/data/api-keys/api-keys-query'
import { ProjectAuthConfigData, useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query'
import { type Schema } from '@/data/database/schemas-query'
import { type FDW } from '@/data/fdw/fdws-query'
import { IntegrationStatus } from '@/data/partners/integration-status-query'
import { type ProjectSecret } from '@/data/secrets/secrets-query'
import { AuthorizedApp, useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query'
import {
IntegrationStatus,
usePartnerIntegrationsQuery,
} from '@/data/partners/integration-status-query'
import { useSecretsQuery, type ProjectSecret } from '@/data/secrets/secrets-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { ResponseError } from '@/types'

export const isStripeSyncEngineInstalled = (schemas: Schema[]) => {
const stripeSchema = findStripeSchema(schemas)
const parsedSchema = parseSchemaComment(stripeSchema?.comment)
return checkIsInstalled(parsedSchema.status)
}

type ProjectOAuthIntegrationData = {
apiKeys: APIKey[]
edgeFunctionSecrets: ProjectSecret[]
authConfig: ProjectAuthConfigData | undefined
partnerIntegrations: IntegrationStatus[]
oauthApps: AuthorizedApp[]
}

/**
* Gathers all the information needed to determine if an arbitrary OAuth integration is installed.
*/
export const useProjectOAuthIntegrationData = (
projectRef: string | undefined,
{ enabled = true } = {}
): {
data: ProjectOAuthIntegrationData
error: ResponseError | null
isError: boolean
isLoading: boolean
isPending: boolean
isSuccess: boolean
} => {
const { data: org } = useSelectedOrganizationQuery({ enabled })
const queries = {
apiKeys: useAPIKeysQuery({ projectRef, reveal: false }, { enabled }),
edgeFunctionSecrets: useSecretsQuery({ projectRef }, { enabled }),
authConfig: useAuthConfigQuery({ projectRef }, { enabled }),
partnerIntegrations: usePartnerIntegrationsQuery({ projectRef }, { enabled }),
oauthApps: useAuthorizedAppsQuery({ slug: org?.slug }, { enabled: !!org }),
}

// memoize to prevent object creation from triggering a re-render when the result data is used
// as a dependency.
const data = useMemo(() => {
return {
apiKeys: queries.apiKeys.data ?? [],
edgeFunctionSecrets: queries.edgeFunctionSecrets.data ?? [],
authConfig: queries.authConfig.data,
partnerIntegrations: queries.partnerIntegrations.data ?? [],
oauthApps: queries.oauthApps.data ?? [],
}
}, [
queries.apiKeys.data,
queries.edgeFunctionSecrets.data,
queries.authConfig.data,
queries.partnerIntegrations.data,
queries.oauthApps.data,
])

return {
data,
error:
Object.values(queries)
.map((q) => q.error)
.find((e) => !!e) || null,
isError: Object.values(queries).some((x) => x.isError),
isLoading: Object.values(queries).some((x) => x.isLoading),
isPending: Object.values(queries).some((x) => x.isPending),
isSuccess: Object.values(queries).every((x) => x.isSuccess),
}
}

const isPartnerIntegrationReady = (
projectData: ProjectOAuthIntegrationData,
integration: IntegrationDefinition
) => {
return projectData.partnerIntegrations.some(
(i) => i.listing_slug === integration.id && i.status === 'ready'
)
}

const isOAuthAppAuthorized = (
projectData: ProjectOAuthIntegrationData,
integration: IntegrationDefinition
) => {
return (
!!integration.oauthAppId &&
projectData.oauthApps.some((app) => app.app_id === integration.oauthAppId)
)
}

const isSecretKeyPrefixPresent = (projectData: ProjectOAuthIntegrationData, prefix?: string) => {
if (!prefix) return false
return projectData.apiKeys.some((key) => key.type === 'secret' && key.name.startsWith(prefix))
}

const isEdgeFunctionSecretPresent = (
projectData: ProjectOAuthIntegrationData,
secretName?: string
) => {
if (!secretName) return false
return projectData.edgeFunctionSecrets.some((secret) => secret.name === secretName)
}

export const isOAuthInstalled = ({
integration,
apiKeys,
secrets,
partnerIntegrations: partnerIntegrations,
projectData,
}: {
integration: IntegrationDefinition
apiKeys: APIKey[]
secrets: ProjectSecret[]
partnerIntegrations: IntegrationStatus[]
projectData: ProjectOAuthIntegrationData
}) => {
if (integration.installIdentificationMethod === 'integration_status') {
return partnerIntegrations.some(
(i) => i.listing_slug === integration.id && i.status === 'ready'
// Special-case logic for in-development integrations
if (integration.id === 'resend') {
return (
projectData.authConfig?.SMTP_HOST === 'smtp.resend.com' &&
// Keying off of OAuth App instead of integration status lets us show the integration as
// installed for partner-initiated connections, without degrading the experience for
// marketplace-initiated installations.
isOAuthAppAuthorized(projectData, integration)
)
}

if (integration.installIdentificationMethod === 'secret_key_prefix') {
const prefix = integration.secretKeyPrefix
if (!prefix) return false
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 apiKeys.some((key) => key.type === 'secret' && key.name.startsWith(prefix))
if (integration.id === 'aikido') {
return isOAuthAppAuthorized(projectData, integration)
}

if (integration.installIdentificationMethod === 'edge_function_secret_name') {
const secretName = integration.edgeFunctionSecretName
if (!secretName) return false
if (integration.id === 'doppler') {
return (
isEdgeFunctionSecretPresent(projectData, 'DOPPLER_CONFIG') &&
isPartnerIntegrationReady(projectData, integration)
)
}

return secrets.some((secret) => secret.name === secretName)
// Fallback logic for generic OAuth integrations.
if (integration.installIdentificationMethod === 'integration_status') {
return isPartnerIntegrationReady(projectData, integration)
}

if (integration.installIdentificationMethod === 'oauth_authorization') {
return isOAuthAppAuthorized(projectData, integration)
}

// Special-case logic that is still encoded as database fields, consider removing.
if (integration.installIdentificationMethod === 'secret_key_prefix') {
return isSecretKeyPrefixPresent(projectData, integration.secretKeyPrefix)
}

if (integration.installIdentificationMethod === 'edge_function_secret_name') {
return isEdgeFunctionSecretPresent(projectData, integration.edgeFunctionSecretName)
}

return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const useAvailableIntegrations = () => {
content,
built_by: authorName,
listing_logo: listingLogo,
oauth_app_id: oauthAppId,
} = integration

const status = undefined
Expand All @@ -109,6 +110,7 @@ export const useAvailableIntegrations = () => {
installIdentificationMethod: installMethod ?? undefined,
secretKeyPrefix: secretKeyPrefix ?? undefined,
edgeFunctionSecretName: edgeFunctionSecretName ?? undefined,
oauthAppId: oauthAppId ?? undefined,
listingId: listingId ?? undefined,
author,
requiredExtensions: [],
Expand Down
Loading
Loading