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