OverviewTab.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. import { zodResolver } from '@hookform/resolvers/zod'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import { ExternalLink } from 'lucide-react'
  4. import Link from 'next/link'
  5. import { useCallback, useEffect, useRef, useState } from 'react'
  6. import { useForm } from 'react-hook-form'
  7. import { toast } from 'sonner'
  8. import {
  9. Button,
  10. Form,
  11. FormControl,
  12. FormField,
  13. Sheet,
  14. SheetContent,
  15. SheetFooter,
  16. SheetHeader,
  17. SheetSection,
  18. SheetTitle,
  19. } from 'ui'
  20. import { Admonition } from 'ui-patterns'
  21. import { Input } from 'ui-patterns/DataInputs/Input'
  22. import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
  23. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  24. import * as z from 'zod'
  25. import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab'
  26. import { IntegrationOverviewTabV2 } from '../../Integration/IntegrationOverviewTabV2'
  27. import { InstallationError } from './InstallationError'
  28. import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions'
  29. import { StatusDisplay } from './StatusDisplay'
  30. import {
  31. canInstall as checkCanInstall,
  32. hasInstallError,
  33. hasUninstallError,
  34. isInstallDone,
  35. isInstalled,
  36. isInstalling,
  37. isUninstallDone,
  38. isUninstalling,
  39. } from './stripe-sync-status'
  40. import { StripeSyncChangesCard } from './StripeSyncChangesCard'
  41. import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
  42. import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus'
  43. import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation'
  44. import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation'
  45. import { useSchemasQuery } from '@/data/database/schemas-query'
  46. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  47. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  48. import { useTrack } from '@/lib/telemetry/track'
  49. const installFormSchema = z.object({
  50. stripeSecretKey: z.string().min(1, 'Stripe API key is required'),
  51. })
  52. export const StripeSyncEngineOverviewTab = () => {
  53. const track = useTrack()
  54. const hasTrackedInstallFailed = useRef(false)
  55. const { data: project } = useSelectedProjectQuery()
  56. const isMarketplaceEnabled = useIsMarketplaceEnabled()
  57. const [showUninstallModal, setShowUninstallModal] = useState(false)
  58. const [shouldShowInstallSheet, setShouldShowInstallSheet] = useState(false)
  59. // These flags bridge the gap between mutation success and schema update
  60. const [isInstallInitiated, setIsInstallInitiated] = useState(false)
  61. const [isUninstallInitiated, setIsUninstallInitiated] = useState(false)
  62. const formId = 'stripe-sync-install-form'
  63. const form = useForm<z.infer<typeof installFormSchema>>({
  64. resolver: zodResolver(installFormSchema as any),
  65. defaultValues: { stripeSecretKey: '' },
  66. mode: 'onSubmit',
  67. })
  68. const {
  69. schemaComment,
  70. schemaComment: { status: installationStatus },
  71. latestAvailableVersion,
  72. timedOut,
  73. } = useStripeSyncStatus()
  74. // Check permissions for managing function secrets
  75. const { can: canManageSecrets } = useAsyncCheckPermissions(
  76. PermissionAction.FUNCTIONS_SECRET_WRITE,
  77. '*'
  78. )
  79. const installed = isInstalled(installationStatus)
  80. const installError = hasInstallError(installationStatus)
  81. const uninstallError = hasUninstallError(installationStatus)
  82. const installInProgress = isInstalling(installationStatus)
  83. const uninstallInProgress = isUninstalling(installationStatus)
  84. const installDone = isInstallDone(installationStatus)
  85. const uninstallDone = isUninstallDone(installationStatus)
  86. // Detect if this is an upgrade (both old and new versions present)
  87. let oldVersion
  88. let newVersion
  89. if (installed) {
  90. // when installed we compare the installed version against the latest available
  91. oldVersion = schemaComment?.newVersion
  92. newVersion = latestAvailableVersion
  93. } else {
  94. // otherwise compare the old and new versions from the schema
  95. oldVersion = schemaComment?.oldVersion
  96. newVersion = schemaComment?.newVersion
  97. }
  98. const upgradeAvailable = !!(oldVersion && newVersion && oldVersion !== newVersion)
  99. const upgradeDone = latestAvailableVersion == schemaComment?.newVersion
  100. const {
  101. mutate: installStripeSync,
  102. isPending: isInstallRequested,
  103. error: installRequestError,
  104. reset: resetInstallError,
  105. } = useStripeSyncInstallMutation({
  106. onSuccess: () => {
  107. toast.success(
  108. upgradeAvailable ? 'Stripe Sync upgrade started' : 'Stripe Sync installation started'
  109. )
  110. setShouldShowInstallSheet(false)
  111. form.reset()
  112. setIsInstallInitiated(true)
  113. },
  114. })
  115. const { mutate: uninstallStripeSync, isPending: isUninstallRequested } =
  116. useStripeSyncUninstallMutation({
  117. onSuccess: () => {
  118. toast.success('Stripe Sync uninstallation started')
  119. setShowUninstallModal(false)
  120. setIsUninstallInitiated(true)
  121. },
  122. })
  123. // Combine schema status with mutation/initiated states for UI
  124. const installing = (installInProgress || isInstallRequested || isInstallInitiated) && !timedOut
  125. const uninstalling =
  126. (uninstallInProgress || isUninstallRequested || isUninstallInitiated) && !timedOut
  127. const canInstall = checkCanInstall(installationStatus) && !installed && !installing
  128. const hasError = (uninstallError || installError) && ((!uninstalling && !installing) || timedOut)
  129. // Poll for schema changes during transitions
  130. useSchemasQuery(
  131. { projectRef: project?.ref, connectionString: project?.connectionString },
  132. { refetchInterval: installing || uninstalling ? 5000 : false }
  133. )
  134. const handleUninstall = useCallback(() => {
  135. if (!project?.ref) return
  136. uninstallStripeSync({
  137. projectRef: project.ref,
  138. startTime: Date.now(),
  139. })
  140. }, [project?.ref, uninstallStripeSync])
  141. const handleOpenInstallSheet = useCallback(() => {
  142. resetInstallError()
  143. setShouldShowInstallSheet(true)
  144. }, [resetInstallError])
  145. const handleCloseInstallSheet = (isOpen: boolean) => {
  146. if (isInstallRequested) return
  147. setShouldShowInstallSheet(isOpen)
  148. if (!isOpen) {
  149. form.reset()
  150. resetInstallError()
  151. }
  152. }
  153. // Track install failures
  154. useEffect(() => {
  155. if (!installError) {
  156. hasTrackedInstallFailed.current = false
  157. return
  158. }
  159. if (!hasTrackedInstallFailed.current) {
  160. hasTrackedInstallFailed.current = true
  161. track('integration_install_failed', {
  162. integrationName: 'stripe_sync_engine',
  163. })
  164. }
  165. }, [installError, track])
  166. // Clear install initiated flag once schema reflects successful completion
  167. // For errors, the flag is cleared when user manually retries (handleOpenInstallSheet)
  168. useEffect(() => {
  169. if (isInstallInitiated && installDone && upgradeDone && !installError) {
  170. setIsInstallInitiated(false)
  171. }
  172. }, [isInstallInitiated, installDone, upgradeDone, installError])
  173. // Clear uninstall initiated flag once schema is removed or error
  174. useEffect(() => {
  175. if (isUninstallInitiated && uninstallDone) {
  176. setIsUninstallInitiated(false)
  177. }
  178. }, [isUninstallInitiated, uninstallDone])
  179. return (
  180. <>
  181. {isMarketplaceEnabled ? (
  182. <IntegrationOverviewTabV2>
  183. {hasError && (
  184. <InstallationError
  185. error={uninstallError ? 'uninstall' : 'install'}
  186. handleUninstall={handleUninstall}
  187. handleOpenInstallSheet={handleOpenInstallSheet}
  188. isUpgrade={upgradeAvailable}
  189. installing={installing}
  190. uninstalling={uninstalling}
  191. />
  192. )}
  193. {!installed && !uninstalling && !uninstallError ? (
  194. <IntegrationNotInstalledActions
  195. hideInstallCTA
  196. installing={installing}
  197. canInstall={canInstall}
  198. isUninstallRequested={isUninstallRequested}
  199. handleUninstall={handleUninstall}
  200. setShouldShowInstallSheet={setShouldShowInstallSheet}
  201. />
  202. ) : (
  203. (installed || uninstalling || uninstallError) && (
  204. <IntegrationInstalledActions
  205. disabled={installing || uninstalling || !canManageSecrets}
  206. upgradeAvailable={upgradeAvailable}
  207. installing={installing}
  208. uninstalling={uninstalling}
  209. isUninstallRequested={isUninstallRequested}
  210. setShouldShowInstallSheet={setShouldShowInstallSheet}
  211. setShowUninstallModal={setShowUninstallModal}
  212. />
  213. )
  214. )}
  215. </IntegrationOverviewTabV2>
  216. ) : (
  217. <IntegrationOverviewTab
  218. alert={
  219. hasError ? (
  220. <InstallationError
  221. error={uninstallError ? 'uninstall' : 'install'}
  222. handleUninstall={handleUninstall}
  223. handleOpenInstallSheet={handleOpenInstallSheet}
  224. isUpgrade={upgradeAvailable}
  225. installing={installing}
  226. uninstalling={uninstalling}
  227. />
  228. ) : null
  229. }
  230. status={
  231. <StatusDisplay
  232. status={installationStatus}
  233. isInstallRequested={isInstallRequested}
  234. isInstallInitiated={isInstallInitiated}
  235. isUninstallRequested={isUninstallRequested}
  236. isUninstallInitiated={isUninstallInitiated}
  237. isUpgrade={upgradeAvailable}
  238. timedOut={timedOut}
  239. />
  240. }
  241. actions={
  242. !installed && !uninstalling && !uninstallError ? (
  243. <>
  244. <StripeSyncChangesCard
  245. installationStatus={installationStatus}
  246. isUpgrade={upgradeAvailable}
  247. />
  248. <IntegrationNotInstalledActions
  249. className="mt-4"
  250. installing={installing}
  251. canInstall={canInstall}
  252. isUninstallRequested={isUninstallRequested}
  253. handleUninstall={handleUninstall}
  254. setShouldShowInstallSheet={setShouldShowInstallSheet}
  255. />
  256. </>
  257. ) : installed || uninstalling || uninstallError ? (
  258. <>
  259. <StripeSyncChangesCard
  260. installationStatus={installationStatus}
  261. isUpgrade={upgradeAvailable}
  262. />
  263. <IntegrationInstalledActions
  264. className="mt-4"
  265. disabled={installing || uninstalling || !canManageSecrets}
  266. upgradeAvailable={upgradeAvailable}
  267. installing={installing}
  268. uninstalling={uninstalling}
  269. isUninstallRequested={isUninstallRequested}
  270. setShouldShowInstallSheet={setShouldShowInstallSheet}
  271. setShowUninstallModal={setShowUninstallModal}
  272. />
  273. </>
  274. ) : null
  275. }
  276. >
  277. <Sheet open={!!shouldShowInstallSheet} onOpenChange={handleCloseInstallSheet}>
  278. <SheetContent size="lg" tabIndex={undefined} className="flex flex-col gap-0">
  279. <Form {...form}>
  280. <form
  281. id={formId}
  282. onSubmit={form.handleSubmit(({ stripeSecretKey }) => {
  283. if (!project?.ref) return
  284. installStripeSync({
  285. projectRef: project.ref,
  286. stripeSecretKey,
  287. startTime: Date.now(),
  288. })
  289. })}
  290. className="overflow-auto grow px-0 flex flex-col"
  291. >
  292. <SheetHeader>
  293. <SheetTitle>
  294. {upgradeAvailable ? 'Upgrade' : 'Install'} Stripe Sync Engine
  295. </SheetTitle>
  296. </SheetHeader>
  297. <SheetSection className="flex-1 flex flex-col gap-y-6">
  298. <StripeSyncChangesCard
  299. installationStatus={installationStatus}
  300. isUpgrade={upgradeAvailable}
  301. />
  302. <h3 className="heading-default">Configuration</h3>
  303. <div className="flex flex-col gap-y-2">
  304. <FormField
  305. control={form.control}
  306. name="stripeSecretKey"
  307. render={({ field }) => (
  308. <FormItemLayout
  309. layout="flex-row-reverse"
  310. label="Stripe API secret key"
  311. description="Your Stripe secret key. Requires write access to Webhook Endpoints and read-only access to all other categories."
  312. >
  313. <FormControl className="col-span-8">
  314. <Input
  315. id="stripe_api_key"
  316. name="stripe_api_key"
  317. placeholder="Enter your Stripe API key"
  318. autoComplete="stripe-api-key"
  319. reveal={false}
  320. disabled={isInstallRequested}
  321. type="password"
  322. value={field.value}
  323. onChange={(e) => field.onChange(e.target.value)}
  324. />
  325. </FormControl>
  326. </FormItemLayout>
  327. )}
  328. />
  329. <div className="flex items-center gap-x-2">
  330. <Button asChild type="default" icon={<ExternalLink />}>
  331. <Link
  332. target="_blank"
  333. rel="noopener noreferrer"
  334. href="https://dashboard.stripe.com/apikeys"
  335. >
  336. Get Stripe API key
  337. </Link>
  338. </Button>
  339. <Button asChild type="default" icon={<ExternalLink />}>
  340. <Link
  341. target="_blank"
  342. rel="noopener noreferrer"
  343. href="https://support.stripe.com/questions/what-are-stripe-api-keys-and-how-to-find-them"
  344. >
  345. What are Stripe API keys?
  346. </Link>
  347. </Button>
  348. </div>
  349. </div>
  350. {installRequestError && (
  351. <Admonition
  352. type="destructive"
  353. title="Installation failed"
  354. description={installRequestError.message}
  355. />
  356. )}
  357. </SheetSection>
  358. <SheetFooter>
  359. <Button
  360. type="default"
  361. disabled={isInstallRequested}
  362. onClick={() => handleCloseInstallSheet(false)}
  363. >
  364. Cancel
  365. </Button>
  366. <Button
  367. form={formId}
  368. htmlType="submit"
  369. type="primary"
  370. loading={isInstallRequested}
  371. disabled={!form.formState.isValid || isInstallRequested}
  372. >
  373. {isInstallRequested
  374. ? upgradeAvailable
  375. ? 'Upgrading'
  376. : 'Installing'
  377. : upgradeAvailable
  378. ? 'Upgrade integration'
  379. : 'Install integration'}
  380. </Button>
  381. </SheetFooter>
  382. </form>
  383. </Form>
  384. </SheetContent>
  385. </Sheet>
  386. </IntegrationOverviewTab>
  387. )}
  388. <ConfirmationModal
  389. visible={showUninstallModal}
  390. title="Uninstall Stripe Sync Engine"
  391. confirmLabel="Uninstall"
  392. confirmLabelLoading="Uninstalling..."
  393. variant="destructive"
  394. loading={isUninstallRequested}
  395. onCancel={() => setShowUninstallModal(false)}
  396. onConfirm={handleUninstall}
  397. >
  398. <p className="text-sm text-foreground-light">
  399. Are you sure you want to uninstall the Stripe Sync Engine? This will:
  400. </p>
  401. <ul className="list-disc pl-5 mt-2 text-sm text-foreground-light space-y-1">
  402. <li>
  403. Remove the <code className="text-code-inline">stripe</code> schema and all tables
  404. </li>
  405. <li>Delete all synced Stripe data</li>
  406. <li>Remove the associated Edge Functions</li>
  407. <li>Remove the scheduled sync jobs</li>
  408. </ul>
  409. <p className="mt-4 text-sm text-foreground-light font-medium">
  410. This action cannot be undone.
  411. </p>
  412. </ConfirmationModal>
  413. </>
  414. )
  415. }