feat(mgmt-api): add ability to choose orgs (#43549)

Related to
https://github.com/supabase/platform/compare/raulb/fix-API-906

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Raúl BarrosoandIvan Vasilov authored and GitHub committed 2026-03-11 09:01:49 +00:00
1 parent dc98af91b6
commit 87e3e8aa2a
6 files changed
+202 -60

No files matched your search

@@ -1,4 +1,5 @@
import { OrganizationSelector } from 'components/ui/org-selector'
import { ProjectClaimLayout } from './layout'
export interface ProjectClaimChooseOrgProps {
@@ -16,7 +17,11 @@ export function ProjectClaimChooseOrg({ onChoose }: ProjectClaimChooseOrgProps)
project will be transferred to Supabase organization.
</p>
<p className="text-sm text-foreground-light">Please select an organization to continue.</p>
<OrganizationSelector onSelect={onChoose} maxOrgsToShow={MAX_ORGS_TO_SHOW} />
<OrganizationSelector
onSelect={onChoose}
maxOrgsToShow={MAX_ORGS_TO_SHOW}
canCreateNewOrg
/>
</div>
</ProjectClaimLayout>
)
+21 -14
View File
@@ -1,18 +1,19 @@
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { parseAsString, useQueryState } from 'nuqs'
import type { Organization } from 'types'
import { useMemo, useState } from 'react'
import { Badge, Button, Card, CardHeader, CardTitle, Input_Shadcn_ } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { ButtonTooltip } from './ButtonTooltip'
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import type { Organization } from '@/types'
export interface ProjectClaimChooseOrgProps {
onSelect: (orgSlug: string) => void
maxOrgsToShow?: number
canCreateNewOrg: boolean
}
const OrganizationCard = ({
@@ -84,7 +85,11 @@ const OrganizationCard = ({
)
}
export function OrganizationSelector({ onSelect, maxOrgsToShow = 5 }: ProjectClaimChooseOrgProps) {
export function OrganizationSelector({
onSelect,
maxOrgsToShow = 5,
canCreateNewOrg,
}: ProjectClaimChooseOrgProps) {
const {
data: organizations = [],
isPending: isLoadingOrgs,
@@ -162,14 +167,16 @@ export function OrganizationSelector({ onSelect, maxOrgsToShow = 5 }: ProjectCla
</div>
</>
)}
<Card className="flex items-center justify-between border-dashed pr-6">
<CardHeader className="border-none">
<CardTitle>Need a new organization?</CardTitle>
</CardHeader>
<Button size="small" className="" asChild type="default">
<Link href={`/new?${searchParams.toString()}`}>New Organization</Link>
</Button>
</Card>
{canCreateNewOrg && (
<Card className="flex items-center justify-between border-dashed pr-6">
<CardHeader className="border-none">
<CardTitle>Need a new organization?</CardTitle>
</CardHeader>
<Button size="small" className="" asChild type="default">
<Link href={`/new?${searchParams.toString()}`}>New Organization</Link>
</Button>
</Card>
)}
</div>
)
}
@@ -65,7 +65,7 @@ export const useEntitlementsQuery = <TData = EntitlementsData>(
}: UseCustomQueryOptions<EntitlementsData, EntitlementsError, TData> = {}
) => {
return useQuery<EntitlementsData, EntitlementsError, TData>({
queryKey: [organizationKeys.entitlements(slug)],
queryKey: organizationKeys.entitlements(slug),
queryFn: ({ signal }) => getEntitlements({ slug }, signal),
enabled: enabled && typeof slug !== 'undefined',
...options,
@@ -6,15 +6,17 @@ import type { ResponseError, UseCustomMutationOptions } from '@/types'
type ConfirmAccountRequestVariables = {
arId: string
organizationId?: number
}
async function confirmAccountRequest({ arId }: ConfirmAccountRequestVariables) {
async function confirmAccountRequest({ arId, organizationId }: ConfirmAccountRequestVariables) {
if (!arId) throw new Error('Account request ID is required')
const { data, error } = await post(
'/platform/stripe/fabric/provisioning/account_requests/{id}/confirm',
{
params: { path: { id: arId } },
body: { organization_id: organizationId },
}
)
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -12,17 +12,27 @@ import {
} from 'ui'
import APIAuthorizationLayout from '@/components/layouts/APIAuthorizationLayout'
import { OrganizationSelector } from '@/components/ui/org-selector'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import { useConfirmAccountRequestMutation } from '@/data/partners/stripe-fabric-confirm-mutation'
import { accountRequestQueryOptions } from '@/data/partners/stripe-fabric-query'
import {
accountRequestQueryOptions,
type AccountRequestDetails,
} from '@/data/partners/stripe-fabric-query'
import { withAuth } from '@/hooks/misc/withAuth'
import { useSignOut } from '@/lib/auth'
type OrgSummary = NonNullable<AccountRequestDetails['linked_organization']>
const StripeFabricLoginPage = () => {
const router = useRouter()
const { ar_id } = useParams()
const signOut = useSignOut()
const [selectedOrg, setSelectedOrg] = useState<OrgSummary | null>(null)
const [orgConfirmed, setOrgConfirmed] = useState(false)
const {
data: accountRequest,
isPending,
@@ -30,6 +40,7 @@ const StripeFabricLoginPage = () => {
isError,
error,
} = useQuery(accountRequestQueryOptions({ arId: ar_id }))
const { data: organizations = [] } = useOrganizationsQuery()
const {
mutateAsync: confirmAccountRequest,
@@ -46,47 +57,65 @@ const StripeFabricLoginPage = () => {
}
}, [router.isReady, ar_id, router])
const handleApprove = async () => {
if (!ar_id) return
confirmAccountRequest({ arId: ar_id })
// The onSuccess handler in the mutation will show a success screen, on error it'll show a toast, so we don't need
// to do anything else here
const handleApprove = async (organizationId?: number) => {
if (!ar_id || isConfirming) return
confirmAccountRequest({ arId: ar_id, organizationId })
}
// linked_organization is set when an org is already linked to this Stripe account+org pair
// userOrgs is the list of user's orgs to pick from (only when no linked org)
const linkedOrg = accountRequest?.linked_organization
const userOrgs = organizations ?? []
const emailMatches = accountRequest?.email_matches ?? false
const orgCount = userOrgs.length
// isReauth = org already linked, user is just completing the authorization flow again
const isReauth = !!linkedOrg
const isLinking = isReauth || orgCount >= 1
const loadingText = isReauth
? 'Completing authorization...'
: isLinking
? 'Linking your organization...'
: 'Setting up your organization...'
const successTitle = isReauth
? 'Authorization Complete'
: isLinking
? 'Organization Linked'
: 'Organization Created'
const successDescription = isReauth
? `Your Stripe account is connected to ${linkedOrg?.name}.`
: isLinking
? 'Your Supabase organization has been linked to your Stripe account.'
: 'Your Supabase organization has been created and linked to your Stripe account.'
return (
<APIAuthorizationLayout>
<div className="flex flex-col items-center justify-center h-full">
<div className="flex flex-col items-center min-h-[500px]">
{isConfirming ? (
<>
<LogoLoader />
<p className="pt-4 text-foreground-light">Setting up your organization...</p>
<p className="pt-4 text-foreground-light">{loadingText}</p>
</>
) : isConfirmed ? (
<>
<h2 className="py-2 text-lg font-medium">Organization Created</h2>
<p className="text-foreground-light">
Your Supabase organization has been linked successfully.
</p>
<h2 className="py-2 text-lg font-medium">{successTitle}</h2>
<p className="text-foreground-light">{successDescription}</p>
<p className="pt-4 text-sm text-foreground-lighter">You can close this window.</p>
</>
) : isPending ? (
<LogoLoader />
) : isSuccess ? (
<>
<h2 className="py-2 text-lg font-medium">Stripe Fabric Account Request</h2>
<h2 className="py-2 text-lg font-medium">Stripe Account Authorization</h2>
<p className="text-center text-foreground-light">
Stripe Fabric wants to create a Supabase organization for{' '}
Stripe wants to link a Supabase organization for{' '}
<strong>{accountRequest.email}</strong>
{accountRequest.name && <> ({accountRequest.name})</>}.
</p>
{(accountRequest as any).email_matches ? (
<div className="py-6">
<Button size="large" type="primary" onClick={handleApprove}>
Approve
</Button>
</div>
) : (
{!emailMatches ? (
<>
<Alert_Shadcn_ variant="warning" className="mt-4">
<WarningIcon />
@@ -102,10 +131,106 @@ const StripeFabricLoginPage = () => {
</Button>
</div>
</>
) : linkedOrg ? (
// Org already linked to this Stripe account — inform user, no choice
<>
<p className="mt-4 text-sm text-foreground-light text-center">
Your organization <strong>{linkedOrg.name}</strong> is already linked to your
Stripe account. Approve to continue.
</p>
<div className="py-6">
<Button
size="large"
type="primary"
disabled={isConfirming}
onClick={() => handleApprove()}
>
Approve
</Button>
</div>
</>
) : orgCount === 0 ? (
// No orgs at all — a new one will be created
<>
<p className="mt-4 text-sm text-foreground-light text-center">
A new Supabase organization will be created and linked to your Stripe account.
</p>
<div className="py-6">
<Button
size="large"
type="primary"
disabled={isConfirming}
onClick={() => handleApprove()}
>
Approve
</Button>
</div>
</>
) : orgCount === 1 ? (
// Exactly one org — show its name and approve directly
<>
<p className="mt-4 text-sm text-foreground-light text-center">
Your organization <strong>{userOrgs[0].name}</strong> will be linked to your
Stripe account.
</p>
<div className="py-6">
<Button
size="large"
type="primary"
disabled={isConfirming}
onClick={() => handleApprove(userOrgs[0].id)}
>
Approve
</Button>
</div>
</>
) : !orgConfirmed ? (
// 2+ orgs — show picker
<>
<p className="mt-4 text-sm text-foreground-light text-center">
Select the organization you'd like to link to your Stripe account.
</p>
<div className="mt-4 w-96">
<OrganizationSelector
onSelect={(slug) => {
const org = userOrgs.find((o) => o.slug === slug) ?? null
setSelectedOrg(org)
if (org) {
setOrgConfirmed(true)
}
}}
maxOrgsToShow={3}
canCreateNewOrg={false}
/>
</div>
</>
) : (
// 2+ orgs — org selected, show confirmation
<>
<p className="mt-4 text-sm text-foreground-light text-center">
Link <strong>{selectedOrg!.name}</strong> to your Stripe account?
</p>
<div className="py-6 flex flex-col items-center gap-3">
<Button
size="large"
type="primary"
disabled={isConfirming}
onClick={() => handleApprove(selectedOrg!.id)}
>
Approve
</Button>
<button
className="text-sm text-foreground-lighter underline hover:text-foreground-light"
onClick={() => {
setSelectedOrg(null)
setOrgConfirmed(false)
}}
>
Change organization
</button>
</div>
</>
)}
<p className="text-sm text-foreground-lighter text-center">
This will create a new Supabase organization linked to your Stripe account.
</p>
</>
) : isError ? (
<>
+20 -17
View File
@@ -4645,8 +4645,14 @@ export interface components {
}
AccountRequestDetailsDto: {
email: string
email_matches: boolean
expires_at: string
id: string
linked_organization?: {
id: number
name: string
slug: string
}
name?: string
orchestrator: {
stripe?: {
@@ -4840,6 +4846,9 @@ export interface components {
payment_intent_id: string
size?: string
}
ConfirmRequestDto: {
organization_id?: number
}
ConfirmResponseDto: {
organization_slug: string
success: boolean
@@ -5202,12 +5211,7 @@ export interface components {
| {
billing_email: string | null
/** @enum {string|null} */
billing_partner:
| 'fly'
| 'aws_marketplace'
| 'vercel_marketplace'
| 'stripe_product'
| null
billing_partner: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_fabric' | null
id: number
is_owner: boolean
name: string
@@ -6556,7 +6560,7 @@ export interface components {
}[]
billing_cycle_anchor: number
/** @enum {string} */
billing_partner?: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_product'
billing_partner?: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_fabric'
billing_via_partner: boolean
current_period_end: number
current_period_start: number
@@ -7204,6 +7208,7 @@ export interface components {
| 'auth.leaked_password_protection'
| 'auth.advanced_auth_settings'
| 'auth.performance_settings'
| 'auth.password_hibp'
| 'backup.retention_days'
| 'backup.restore_to_new_project'
| 'function.max_count'
@@ -7570,7 +7575,7 @@ export interface components {
OrganizationResponse: {
billing_email: string | null
/** @enum {string|null} */
billing_partner: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_product' | null
billing_partner: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_fabric' | null
id: number
is_owner: boolean
name: string
@@ -7633,7 +7638,7 @@ export interface components {
OrganizationSlugResponse: {
billing_email: string | null
/** @enum {string|null} */
billing_partner: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_product' | null
billing_partner: 'fly' | 'aws_marketplace' | 'vercel_marketplace' | 'stripe_fabric' | null
has_oriole_project: boolean
id: number
name: string
@@ -17241,6 +17246,7 @@ export interface operations {
| 'auth.leaked_password_protection'
| 'auth.advanced_auth_settings'
| 'auth.performance_settings'
| 'auth.password_hibp'
| 'backup.retention_days'
| 'backup.restore_to_new_project'
| 'function.max_count'
@@ -26442,7 +26448,11 @@ export interface operations {
}
cookie?: never
}
requestBody?: never
requestBody: {
content: {
'application/json': components['schemas']['ConfirmRequestDto']
}
}
responses: {
200: {
headers: {
@@ -26522,13 +26532,6 @@ export interface operations {
}
content?: never
}
/** @description Failed to send analytics server event */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
TelemetryFeatureFlagsController_callFeatureFlag: {