feat(studio): move Stripe Projects to connect interstitial (#45862)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature update. Resolves DEPR-553.

## What is the current behavior?

Stripe Projects login still uses the older `APIAuthorizationLayout`
surface, so it does not match the newer shared connect interstitial
pattern used by organisation invites and CLI login.

## What is the new behavior?

Moves `/partners/stripe/projects/login` onto the shared
`InterstitialLayout` while preserving the existing `ar_id` account
request lookup, confirmation mutation, wrong-account sign-out path, and
missing-parameter redirect.

The temporary reviewer mocks have been removed after approval.

## Testing instructions

Automated checks run locally:

- `pnpm --dir apps/studio exec prettier --write
pages/partners/stripe/projects/login.tsx
components/layouts/InterstitialLayout.tsx`
- `pnpm --dir apps/studio exec eslint
pages/partners/stripe/projects/login.tsx
components/layouts/InterstitialLayout.tsx`
- `git diff --check`

`pnpm --dir apps/studio exec tsc --noEmit` was also run earlier on this
branch, but still fails on existing unrelated issues in
`components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx`
and `packages/common/marketplace-client.ts`.

Manual Stripe Projects testing requires a real account request. Opening
`/partners/stripe/projects/login` without an `ar_id` redirects to `/404`
by design.

If you need the real flow:

1. Use the Stripe staging provider. In the Stripe CLI flow, run `export
DEV_MODE=true` so the provider is `Supabase_Staging_Env`.
2. From a local project directory, run `stripe projects init` and
complete the Stripe setup flow.
3. Run `stripe projects add Supabase_Staging_Env`.
4. When the browser opens the Supabase authorization URL, keep the
generated path and query string exactly as-is, including `ar_id`, but
replace only the origin with this PR preview deployment origin.

Note: the staging Stripe Projects flow can still incur real Stripe
costs; use the staging provider and coordinate refunds with team billing
if needed.

## Additional context

This is a deliberately small stacked slice toward the broader shared
connect interstitial work.


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

## Summary by CodeRabbit

* **User Interface**
* Redesigned Stripe authorization login page with improved layout and
visual state management
* Enhanced account row component to support flexible action buttons and
styling
* Added clearer messaging and UI states for authorization scenarios
(pending, success, errors, and account mismatches)

<!-- 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/45862)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Danny White
2026-05-14 12:13:10 +10:00
committed by GitHub
parent 2acceffdc8
commit fd01c665e2
2 changed files with 177 additions and 112 deletions

View File

@@ -103,15 +103,30 @@ export const SupabaseLogo = () => (
</LogoBox>
)
export const PartnerLogo = ({ src, alt }: { src: string; alt: string }) => (
<LogoBox>
<img alt={alt} src={src} className="size-full object-cover" />
</LogoBox>
)
export const InterstitialAccountRow = ({
avatarUrl,
displayName,
action,
className,
}: {
avatarUrl?: string
displayName?: string
action?: ReactNode
className?: string
}) => (
<Card className="border-muted bg-surface-200/50 shadow-none">
<CardContent className="flex items-start gap-3 border-none p-3">
<Card className={cn('shadow-none', !action && 'border-muted bg-surface-200/50', className)}>
<CardContent
className={cn(
'flex gap-3 border-none',
action ? 'items-center px-4 py-3' : 'items-start p-3'
)}
>
<ProfileImage
src={avatarUrl}
alt={displayName}
@@ -123,6 +138,7 @@ export const InterstitialAccountRow = ({
{displayName || <span className="invisible">Loading account</span>}
</p>
</div>
{action}
</CardContent>
</Card>
)

View File

@@ -1,33 +1,36 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { LogOut } from 'lucide-react'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { Button, LogoLoader } from 'ui'
import { Admonition } from 'ui-patterns'
import { Button } from 'ui'
import { Admonition, ShimmeringLoader } from 'ui-patterns'
import { APIAuthorizationLayout } from '@/components/layouts/APIAuthorizationLayout'
import {
InterstitialAccountRow,
InterstitialLayout,
LogoPair,
PartnerLogo,
SupabaseLogo,
} from '@/components/layouts/InterstitialLayout'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useConfirmAccountRequestMutation } from '@/data/partners/stripe-projects-confirm-mutation'
import { accountRequestQueryOptions } from '@/data/partners/stripe-projects-query'
import { withAuth } from '@/hooks/misc/withAuth'
import { useSignOut } from '@/lib/auth'
import { BASE_PATH } from '@/lib/constants'
import { buildStudioPageTitle } from '@/lib/page-title'
import { useProfileNameAndPicture } from '@/lib/profile'
import type { NextPageWithLayout } from '@/types'
const StripeIcon = () => (
<img
src={`${BASE_PATH}/img/icons/stripe-icon.svg`}
alt="Stripe"
width={40}
height={40}
className="mb-2"
/>
)
const PAGE_TITLE = buildStudioPageTitle({ section: 'Authorize Stripe Projects', brand: 'Supabase' })
const StripeProjectsLoginPage = () => {
const StripeProjectsLoginPage: NextPageWithLayout = () => {
const router = useRouter()
const { ar_id } = useParams()
const signOut = useSignOut()
const { username, primaryEmail, avatarUrl } = useProfileNameAndPicture()
const {
data: accountRequest,
@@ -51,7 +54,6 @@ const StripeProjectsLoginPage = () => {
if (!ar_id) {
router.push('/404')
return
}
}, [router.isReady, ar_id, router])
@@ -60,110 +62,157 @@ const StripeProjectsLoginPage = () => {
confirmAccountRequest({ arId: ar_id })
}
const isPending = isQueryPending
const isSuccess = isQuerySuccess
const isConfirmed = isConfirmationSuccess
const isConfirming = isConfirmationPending
const isError = isQueryError
const linkedOrg = accountRequest?.linked_organization
const emailMatches = accountRequest?.email_matches ?? false
const loadingText = linkedOrg ? 'Authorizing...' : 'Creating organization...'
const successTitle = linkedOrg ? 'Authorized' : 'Organization created'
const successDescription = linkedOrg
? null
: 'Your Supabase organization has been created and linked to your Stripe account.'
const displayName = primaryEmail ?? username ?? accountRequest?.email ?? ''
const isPending = router.isReady && isQueryPending
const isConfirmed = isConfirmationSuccess
const isConfirming = isConfirmationPending
const isSuccess = isQuerySuccess
const isError = isQueryError
const showAuthorizationState = isSuccess && !isConfirmed
const interstitialDescription = isConfirmed
? undefined
: 'This will create an organization on your behalf in Supabase'
return (
<APIAuthorizationLayout HeadProvider={Head}>
<div className="flex flex-col items-center min-h-[500px] max-w-[400px] mx-auto">
{isConfirming ? (
<>
<LogoLoader />
<p className="pt-4 text-foreground-light">{loadingText}</p>
</>
) : isConfirmed ? (
<>
<StripeIcon />
<h2 className="py-2 text-lg font-medium">{successTitle}</h2>
<p className="text-sm text-center text-foreground-light">
{successDescription && `${successDescription} `}
You can close this window.
</p>
</>
) : isPending ? (
<LogoLoader />
) : isSuccess ? (
<>
<StripeIcon />
<h2 className="py-2 text-lg font-medium text-balance">
Stripe Projects is requesting access
</h2>
<p className="text-sm text-center text-foreground-light text-balance">
Stripe Projects wants to connect to the Supabase account for{' '}
<strong>{accountRequest?.email}</strong>.
{emailMatches && !linkedOrg && (
<> This will create a new Supabase organization linked to Stripe.</>
)}
</p>
{!emailMatches ? (
<>
<Admonition type="warning" className="mt-4">
<p className="text-sm text-foreground-light">
You're signed in as a different account. Sign out and sign back in as{' '}
<strong className="text-foreground">{accountRequest?.email}</strong>. Then
return to Stripe to restart the request.
</p>
</Admonition>
<div className="py-6">
<Button size="small" type="default" onClick={() => signOut()}>
Sign out
</Button>
<>
<Head>
<title>{PAGE_TITLE}</title>
</Head>
<InterstitialLayout
logo={
<LogoPair
left={<PartnerLogo src={`${BASE_PATH}/img/icons/stripe-icon.svg`} alt="Stripe" />}
right={<SupabaseLogo />}
/>
}
title="Authorize Stripe Projects"
description={interstitialDescription}
>
<div className="px-6 pb-6">
{isPending && (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3 rounded-lg border border-secondary p-3">
<ShimmeringLoader className="size-9 flex-shrink-0 rounded-full py-0" />
<div className="min-w-0 flex-1 space-y-2">
<ShimmeringLoader className="h-3 w-20 py-0" />
<ShimmeringLoader className="h-4 w-40 max-w-full py-0" />
</div>
</>
) : linkedOrg ? (
// Org already linked to this Stripe account — inform user and confirm
<>
<Admonition type="note" className="mt-4" showIcon>
<p className="text-sm text-foreground-light">
<strong className="text-foreground">{linkedOrg.name}</strong> is already linked
to Stripe. Authorize Stripe Projects to continue.
</p>
</Admonition>
<div className="py-6">
<Button
size="small"
type="primary"
disabled={isConfirming}
onClick={handleApprove}
>
Authorize Stripe Projects
</Button>
</div>
</>
) : (
// No linked org — a new one will be created
<div className="py-6">
<Button size="small" type="primary" disabled={isConfirming} onClick={handleApprove}>
Authorize Stripe Projects
</Button>
<div className="h-8 w-8 flex-shrink-0" />
</div>
)}
</>
) : isError ? (
<>
<h2 className="py-2 text-lg font-medium text-destructive">Error</h2>
<p className="text-foreground-light">{error?.message}</p>
<div className="py-6">
<Button size="small" type="default" onClick={() => signOut()}>
<div className="flex flex-col gap-2">
<ShimmeringLoader className="h-10 w-full py-0" />
<ShimmeringLoader className="h-10 w-full py-0" />
</div>
</div>
)}
{isConfirmed && (
<div className="flex flex-col gap-4">
<Admonition type="success" title="Stripe Projects authorized" />
<p className="text-center text-xs text-foreground-lighter text-balance">
You can now close this tab.
</p>
</div>
)}
{showAuthorizationState && !emailMatches && (
<div className="flex flex-col gap-3">
<Admonition
type="warning"
description={
<>
You're signed in to a different account. Sign out and sign back in as{' '}
<span className="font-medium text-foreground">{accountRequest?.email}</span>.
Then return to Stripe to restart the request.
</>
}
/>
<Button type="default" block onClick={() => signOut()}>
Sign out
</Button>
</div>
</>
) : null}
</div>
</APIAuthorizationLayout>
)}
{showAuthorizationState && emailMatches && linkedOrg && (
<div className="flex flex-col gap-3">
<Admonition
type="tip"
description={
<>
<span className="font-medium text-foreground">{linkedOrg.name}</span> is already
linked to this Stripe account, and just needs to be confirmed.
</>
}
/>
<div className="flex flex-col gap-2">
<Button type="primary" block loading={isConfirming} onClick={handleApprove}>
Authorize Stripe Projects
</Button>
<Button type="text" block onClick={() => router.push('/')}>
Cancel
</Button>
</div>
</div>
)}
{showAuthorizationState && emailMatches && !linkedOrg && (
<div className="flex flex-col gap-6">
<InterstitialAccountRow
avatarUrl={avatarUrl}
displayName={displayName}
action={
<ButtonTooltip
type="text"
size="small"
className="h-8 w-8 px-0"
onClick={() => signOut()}
icon={
<LogOut size={16} strokeWidth={1.5} className="text-foreground-lighter" />
}
tooltip={{
content: {
side: 'top',
text: 'Sign out',
},
}}
/>
}
/>
<div className="flex flex-col gap-2">
<Button
type="primary"
loading={isConfirming}
disabled={isConfirming}
onClick={handleApprove}
>
Create organization
</Button>
<Button type="text" onClick={() => router.push('/')}>
Cancel
</Button>
</div>
</div>
)}
{isError && (
<div className="flex flex-col gap-3">
<Admonition
type="danger"
title="Unable to load authorization"
description={error?.message}
/>
<Button type="default" block onClick={() => signOut()}>
Sign out
</Button>
</div>
)}
</div>
</InterstitialLayout>
</>
)
}