OverviewTab.tsx446 lines · main
| 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 | |
| 26 | import { IntegrationOverviewTab } from '../../Integration/IntegrationOverviewTab' |
| 27 | import { IntegrationOverviewTabV2 } from '../../Integration/IntegrationOverviewTabV2' |
| 28 | import { InstallationError } from './InstallationError' |
| 29 | import { IntegrationInstalledActions, IntegrationNotInstalledActions } from './IntegrationActions' |
| 30 | import { StatusDisplay } from './StatusDisplay' |
| 31 | import { |
| 32 | canInstall as checkCanInstall, |
| 33 | hasInstallError, |
| 34 | hasUninstallError, |
| 35 | isInstallDone, |
| 36 | isInstalled, |
| 37 | isInstalling, |
| 38 | isUninstallDone, |
| 39 | isUninstalling, |
| 40 | } from './stripe-sync-status' |
| 41 | import { StripeSyncChangesCard } from './StripeSyncChangesCard' |
| 42 | import { useIsMarketplaceEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' |
| 43 | import { useStripeSyncStatus } from '@/components/interfaces/Integrations/templates/StripeSyncEngine/useStripeSyncStatus' |
| 44 | import { useStripeSyncInstallMutation } from '@/data/database-integrations/stripe/stripe-sync-install-mutation' |
| 45 | import { useStripeSyncUninstallMutation } from '@/data/database-integrations/stripe/stripe-sync-uninstall-mutation' |
| 46 | import { useSchemasQuery } from '@/data/database/schemas-query' |
| 47 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 48 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 49 | import { useTrack } from '@/lib/telemetry/track' |
| 50 | |
| 51 | const installFormSchema = z.object({ |
| 52 | stripeSecretKey: z.string().min(1, 'Stripe API key is required'), |
| 53 | }) |
| 54 | |
| 55 | export 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 | } |