From 87e3e8aa2a1437350069c10a566e2305a45b5d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20Barroso?= Date: Wed, 11 Mar 2026 10:01:49 +0100 Subject: [PATCH] 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 --- .../Organization/ProjectClaim/choose-org.tsx | 7 +- apps/studio/components/ui/org-selector.tsx | 35 ++-- .../data/entitlements/entitlements-query.ts | 2 +- .../stripe-fabric-confirm-mutation.ts | 4 +- .../pages/partners/stripe/fabric/login.tsx | 177 +++++++++++++++--- packages/api-types/types/platform.d.ts | 37 ++-- 6 files changed, 202 insertions(+), 60 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/ProjectClaim/choose-org.tsx b/apps/studio/components/interfaces/Organization/ProjectClaim/choose-org.tsx index 74a0d46081c..1a6fbf3481e 100644 --- a/apps/studio/components/interfaces/Organization/ProjectClaim/choose-org.tsx +++ b/apps/studio/components/interfaces/Organization/ProjectClaim/choose-org.tsx @@ -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.

Please select an organization to continue.

- + ) diff --git a/apps/studio/components/ui/org-selector.tsx b/apps/studio/components/ui/org-selector.tsx index 1cc858f322a..5f70220eac5 100644 --- a/apps/studio/components/ui/org-selector.tsx +++ b/apps/studio/components/ui/org-selector.tsx @@ -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 )} - - - Need a new organization? - - - + {canCreateNewOrg && ( + + + Need a new organization? + + + + )} ) } diff --git a/apps/studio/data/entitlements/entitlements-query.ts b/apps/studio/data/entitlements/entitlements-query.ts index bf703f6763e..8c236c9af7b 100644 --- a/apps/studio/data/entitlements/entitlements-query.ts +++ b/apps/studio/data/entitlements/entitlements-query.ts @@ -65,7 +65,7 @@ export const useEntitlementsQuery = ( }: UseCustomQueryOptions = {} ) => { return useQuery({ - queryKey: [organizationKeys.entitlements(slug)], + queryKey: organizationKeys.entitlements(slug), queryFn: ({ signal }) => getEntitlements({ slug }, signal), enabled: enabled && typeof slug !== 'undefined', ...options, diff --git a/apps/studio/data/partners/stripe-fabric-confirm-mutation.ts b/apps/studio/data/partners/stripe-fabric-confirm-mutation.ts index c0a2231583c..438d0c3ff03 100644 --- a/apps/studio/data/partners/stripe-fabric-confirm-mutation.ts +++ b/apps/studio/data/partners/stripe-fabric-confirm-mutation.ts @@ -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 }, } ) diff --git a/apps/studio/pages/partners/stripe/fabric/login.tsx b/apps/studio/pages/partners/stripe/fabric/login.tsx index 5106a99432c..493e48bb916 100644 --- a/apps/studio/pages/partners/stripe/fabric/login.tsx +++ b/apps/studio/pages/partners/stripe/fabric/login.tsx @@ -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 + const StripeFabricLoginPage = () => { const router = useRouter() const { ar_id } = useParams() const signOut = useSignOut() + const [selectedOrg, setSelectedOrg] = useState(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 ( -
+
{isConfirming ? ( <> -

Setting up your organization...

+

{loadingText}

) : isConfirmed ? ( <> -

Organization Created

-

- Your Supabase organization has been linked successfully. -

+

{successTitle}

+

{successDescription}

You can close this window.

) : isPending ? ( ) : isSuccess ? ( <> -

Stripe Fabric Account Request

+

Stripe Account Authorization

- Stripe Fabric wants to create a Supabase organization for{' '} + Stripe wants to link a Supabase organization for{' '} {accountRequest.email} {accountRequest.name && <> ({accountRequest.name})}.

- {(accountRequest as any).email_matches ? ( -
- -
- ) : ( + + {!emailMatches ? ( <> @@ -102,10 +131,106 @@ const StripeFabricLoginPage = () => {
+ ) : linkedOrg ? ( + // Org already linked to this Stripe account — inform user, no choice + <> +

+ Your organization {linkedOrg.name} is already linked to your + Stripe account. Approve to continue. +

+
+ +
+ + ) : orgCount === 0 ? ( + // No orgs at all — a new one will be created + <> +

+ A new Supabase organization will be created and linked to your Stripe account. +

+
+ +
+ + ) : orgCount === 1 ? ( + // Exactly one org — show its name and approve directly + <> +

+ Your organization {userOrgs[0].name} will be linked to your + Stripe account. +

+
+ +
+ + ) : !orgConfirmed ? ( + // 2+ orgs — show picker + <> +

+ Select the organization you'd like to link to your Stripe account. +

+
+ { + const org = userOrgs.find((o) => o.slug === slug) ?? null + setSelectedOrg(org) + if (org) { + setOrgConfirmed(true) + } + }} + maxOrgsToShow={3} + canCreateNewOrg={false} + /> +
+ + ) : ( + // 2+ orgs — org selected, show confirmation + <> +

+ Link {selectedOrg!.name} to your Stripe account? +

+
+ + +
+ )} -

- This will create a new Supabase organization linked to your Stripe account. -

) : isError ? ( <> diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index aa8ed6fabd9..3495760532b 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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: {