Files
supabase/apps/studio/components/interfaces/Integrations/templates/StripeSyncEngine/OverviewTab.tsx
Raminder Singh 2f24238fd9 fix: missing install integration button (#46368)
The install integration button on stripe sync engine page was missing
entirely when the marketplace feature preview was disabled. Now we hide
only when the feature preview is enabled.

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

## Summary by CodeRabbit

* **New Features**
* Enhanced Stripe integration installation prompt handling based on
marketplace configuration.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46368?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-26 15:07:08 +05:30

414 lines
15 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Form,
FormControl,
FormField,
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab'
import { RequiredExtensionsSection } from '../../Integration/RequiredExtensionsSection'
import { InstallationError } from './InstallationError'
import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions'
import {
canInstall as checkCanInstall,
hasInstallError,
hasUninstallError,
isInstallDone,
isInstalled,
isInstalling,
isUninstallDone,
isUninstalling,
} from './stripe-sync-status'
import { StripeSyncChangesCard } from './StripeSyncChangesCard'
import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
const installFormSchema = z.object({
stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
})
const StripeSyncContent = ({ hideInstallCTA = false }: { hideInstallCTA?: boolean }) => {
const track = useTrack()
const hasTrackedInstallFailed = useRef(false)
const { data: project } = useSelectedProjectQuery()
const [showUninstallModal, setShowUninstallModal] = useState(false)
const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false)
// These flags bridge the gap between mutation success and schema update
const [isInstallInitiated, setIsInstallInitiated] = useState(false)
const [isUninstallInitiated, setIsUninstallInitiated] = useState(false)
const formId = 'stripe-sync-install-form'
const form = useForm<z.infer<typeof installFormSchema>>({
resolver: zodResolver(installFormSchema),
defaultValues: { stripeSecretKey: '' },
mode: 'onSubmit',
})
const {
schemaComment,
schemaComment: { status: installationStatus },
latestAvailableVersion,
timedOut,
} = useStripeSyncStatus()
// Check permissions for managing function secrets
const { can: canManageSecrets } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_SECRET_WRITE,
'*'
)
const installed = isInstalled(installationStatus)
const installError = hasInstallError(installationStatus)
const uninstallError = hasUninstallError(installationStatus)
const installInProgress = isInstalling(installationStatus)
const uninstallInProgress = isUninstalling(installationStatus)
const installDone = isInstallDone(installationStatus)
const uninstallDone = isUninstallDone(installationStatus)
// Detect if this is an upgrade (both old and new versions present)
let oldVersion
let newVersion
if (installed) {
// when installed we compare the installed version against the latest available
oldVersion = schemaComment?.newVersion
newVersion = latestAvailableVersion
} else {
// otherwise compare the old and new versions from the schema
oldVersion = schemaComment?.oldVersion
newVersion = schemaComment?.newVersion
}
const upgradeAvailable = !!(oldVersion && newVersion && oldVersion !== newVersion)
const upgradeDone = latestAvailableVersion == schemaComment?.newVersion
const {
mutate: installStripeSync,
isPending: isInstallRequested,
error: installRequestError,
reset: resetInstallError,
} = useStripeSyncInstallMutation({
onSuccess: () => {
toast.success(
upgradeAvailable ? 'Stripe Sync upgrade started' : 'Stripe Sync installation started'
)
setShouldShowInstallSheet(false)
form.reset()
setIsInstallInitiated(true)
},
})
const { mutate: uninstallStripeSync, isPending: isUninstallRequested } =
useStripeSyncUninstallMutation({
onSuccess: () => {
toast.success('Stripe Sync uninstallation started')
setShowUninstallModal(false)
setIsUninstallInitiated(true)
},
})
// Combine schema status with mutation/initiated states for UI
const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
const uninstalling =
(uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
const canInstall = checkCanInstall(installationStatus) && !installed && !installing
const hasError = (uninstallError || installError) && ((!uninstalling && !installing) || timedOut)
// Poll for schema changes during transitions
useSchemasQuery(
{ projectRef: project?.ref, connectionString: project?.connectionString },
{ refetchInterval: installing || uninstalling ? 5000 : false }
)
const handleUninstall = useCallback(() => {
if (!project?.ref) return
uninstallStripeSync({
projectRef: project.ref,
startTime: Date.now(),
})
}, [project?.ref, uninstallStripeSync])
const handleOpenInstallSheet = useCallback(() => {
resetInstallError()
setShouldShowInstallSheet(true)
}, [resetInstallError])
const handleCloseInstallSheet = (isOpen: boolean) => {
if (isInstallRequested) return
setShouldShowInstallSheet(isOpen)
if (!isOpen) {
form.reset()
resetInstallError()
}
}
// Track install failures
useEffect(() => {
if (!installError) {
hasTrackedInstallFailed.current = false
return
}
if (!hasTrackedInstallFailed.current) {
hasTrackedInstallFailed.current = true
track('integration_install_failed', {
integrationName: 'stripe_sync_engine',
})
}
}, [installError, track])
// Clear install initiated flag once schema reflects successful completion
// For errors, the flag is cleared when user manually retries (handleOpenInstallSheet)
useEffect(() => {
if (isInstallInitiated && installDone && upgradeDone && !installError) {
setIsInstallInitiated(false)
}
}, [isInstallInitiated, installDone, upgradeDone, installError])
// Clear uninstall initiated flag once schema is removed or error
useEffect(() => {
if (isUninstallInitiated && uninstallDone) {
setIsUninstallInitiated(false)
}
}, [isUninstallInitiated, uninstallDone])
return (
<>
{hasError && (
<InstallationError
error={uninstallError ? 'uninstall' : 'install'}
handleUninstall={handleUninstall}
handleOpenInstallSheet={handleOpenInstallSheet}
isUpgrade={upgradeAvailable}
installing={installing}
uninstalling={uninstalling}
/>
)}
{!installed && !uninstalling && !uninstallError ? (
<>
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<IntegrationNotInstalledActions
hideInstallCTA={hideInstallCTA}
installing={installing}
canInstall={canInstall}
isUninstallRequested={isUninstallRequested}
handleUninstall={handleUninstall}
setShouldShowInstallSheet={setShouldShowInstallSheet}
/>
</>
) : (
(installed || uninstalling || uninstallError) && (
<>
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<IntegrationInstalledActions
disabled={installing || uninstalling || !canManageSecrets}
upgradeAvailable={upgradeAvailable}
installing={installing}
uninstalling={uninstalling}
isUninstallRequested={isUninstallRequested}
setShouldShowInstallSheet={setShouldShowInstallSheet}
setShowUninstallModal={setShowUninstallModal}
/>
</>
)
)}
<Sheet open={!!shouldShowInstallSheet} onOpenChange={handleCloseInstallSheet}>
<SheetContent size="lg" tabIndex={undefined} className="flex flex-col gap-0">
<Form {...form}>
<form
id={formId}
onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
if (!project?.ref) return
installStripeSync({
projectRef: project.ref,
stripeSecretKey,
startTime: Date.now(),
})
})}
className="overflow-auto grow px-0 flex flex-col"
>
<SheetHeader>
<SheetTitle>
{upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
</SheetTitle>
</SheetHeader>
<SheetSection className="flex-1 flex flex-col gap-y-6">
<StripeSyncChangesCard
installationStatus={installationStatus}
isUpgrade={upgradeAvailable}
/>
<h3 className="heading-default">Configuration</h3>
<div className="flex flex-col gap-y-2">
<FormField
control={form.control}
name="stripeSecretKey"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Stripe API secret key"
description="Your Stripe secret key. Requires write access to Webhook Endpoints and read-only access to all other categories."
>
<FormControl className="col-span-8">
<Input
id="stripe_api_key"
name="stripe_api_key"
placeholder="Enter your Stripe API key"
autoComplete="stripe-api-key"
reveal={false}
disabled={isInstallRequested}
type="password"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItemLayout>
)}
/>
<div className="flex items-center gap-x-2">
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://dashboard.stripe.com/apikeys"
>
Get Stripe API key
</Link>
</Button>
<Button asChild type="default" icon={<ExternalLink />}>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them"
>
What are Stripe API keys?
</Link>
</Button>
</div>
</div>
{installRequestError && (
<Admonition
type="destructive"
title="Installation failed"
description={installRequestError.message}
/>
)}
</SheetSection>
<SheetFooter>
<Button
type="default"
disabled={isInstallRequested}
onClick={() => handleCloseInstallSheet(false)}
>
Cancel
</Button>
<Button
form={formId}
htmlType="submit"
type="primary"
loading={isInstallRequested}
disabled={!form.formState.isValid || isInstallRequested}
>
{isInstallRequested
? upgradeAvailable
? 'Upgrading'
: 'Installing'
: upgradeAvailable
? 'Upgrade integration'
: 'Install integration'}
</Button>
</SheetFooter>
</form>
</Form>
</SheetContent>
</Sheet>
<ConfirmationModal
visible={showUninstallModal}
title="Uninstall Stripe Sync Engine"
confirmLabel="Uninstall"
confirmLabelLoading="Uninstalling..."
variant="destructive"
loading={isUninstallRequested}
onCancel={() => setShowUninstallModal(false)}
onConfirm={handleUninstall}
>
<p className="text-sm text-foreground-light">
Are you sure you want to uninstall the Stripe Sync Engine? This will:
</p>
<ul className="list-disc pl-5 mt-2 text-sm text-foreground-light space-y-1">
<li>
Remove the <code className="text-code-inline">stripe</code> schema and all tables
</li>
<li>Delete all synced Stripe data</li>
<li>Remove the associated Edge Functions</li>
<li>Remove the scheduled sync jobs</li>
</ul>
<p className="mt-4 text-sm text-foreground-light font-medium">
This action cannot be undone.
</p>
</ConfirmationModal>
</>
)
}
export const StripeSyncEngineOverviewTab = () => {
const isMarketplaceEnabled = useIsMarketplaceEnabled()
if (isMarketplaceEnabled) {
return (
<>
<RequiredExtensionsSection />
<StripeSyncContent hideInstallCTA />
</>
)
}
return (
<IntegrationOverviewTab>
<div className="px-4 md:px-10 max-w-4xl space-y-4">
<StripeSyncContent />
</div>
</IntegrationOverviewTab>
)
}