mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user