mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
## What kind of change does this PR introduce? Bug fix. Resolves DEPR-539. ## What is the current behavior? When a focused child unmounts, Radix can move focus to the Sheet wrapper and break the expected tab order. Several callsites suppress the wrapper's tabindex individually. ## What is the new behavior? Sheet still focuses its first interactive child when opened, but the wrapper itself is no longer focusable by default. Callers can opt in with an explicit `tabIndex` when needed. ## Additional context ### Testing Compare this Studio experience on both this branch and `master`: 1. Open any project with an Edge Function. 2. Go to **Edge Functions**, open the function, then click **Test**. 3. Under **Headers**, click **Add Headers**. Click the first header key input, then Tab slowly through the header inputs and remove buttons. On `master`, focus can jump to the whole Sheet. On this branch, focus stays on the controls in order. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved keyboard focus behavior across sheets and panels. * Sheets now focus the first available interactive element when opened, without adding unnecessary focus targets. * Preserved support for programmatic focus and prevented focus from unexpectedly moving to the sheet when focused content is removed. * Updated authentication, integrations, connection, logging, storage, and other sheet interfaces consistently. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
414 lines
15 KiB
TypeScript
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/Admonition'
|
|
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" 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 variant="default" icon={<ExternalLink />}>
|
|
<Link
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
href="https://dashboard.stripe.com/apikeys"
|
|
>
|
|
Get Stripe API key
|
|
</Link>
|
|
</Button>
|
|
<Button asChild variant="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
|
|
variant="default"
|
|
disabled={isInstallRequested}
|
|
onClick={() => handleCloseInstallSheet(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
form={formId}
|
|
type="submit"
|
|
variant="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>
|
|
)
|
|
}
|