feat: support pending changes (#36333)

Adds support for the new Orb pending subscription change flow that has been added to the backend.

By default, the new feature is disabled (enabled after merge on staging). If disabled, this should work just like before with the regular `confirmPayment` function that triggers 3DS if needed and properly lets a user confirm their additional factor.

With the new flag, we only create a payment method, as we will do another payment intent that is set up for future usage and that may require 3DS - so we avoid the possible double confirmation (setup confirmation + payment intent). The organization creation endpoint can either return a full organization (i.e. on Free Plan or immediate payment success) or a payment_intent_secret. In case of the secret being returned, we need to confirm the secret with the Stripe SDK.

Moved the Stripe Elements further down to avoid the entire form reloading (especially billing address) in case of a payment failure.
This commit is contained in:
Kevin Grüneberg authored and GitHub committed 2025-06-13 10:27:09 +08:00
1 parent de3afc5394
commit ff318d07f0
10 files changed
+493 -391

No files matched your search

@@ -0,0 +1,39 @@
import { useStripe } from '@stripe/react-stripe-js'
import { PaymentIntentResult } from '@stripe/stripe-js'
import { useEffect } from 'react'
import { LoadingLine } from 'ui'
export const PaymentConfirmation = ({
paymentIntentSecret,
onPaymentIntentConfirm,
onLoadingChange,
paymentMethodId,
onError,
}: {
paymentIntentSecret: string
paymentMethodId: string
onPaymentIntentConfirm: (response: PaymentIntentResult) => void
onLoadingChange: (loading: boolean) => void
onError?: (error: Error) => void
}) => {
const stripe = useStripe()
useEffect(() => {
if (stripe && paymentIntentSecret) {
onLoadingChange(true)
stripe!
.confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId })
.then((res) => {
onPaymentIntentConfirm(res)
onLoadingChange(false)
})
.catch((err) => {
console.error(err)
onError?.(err)
onLoadingChange(false)
})
}
}, [paymentIntentSecret, stripe])
return <LoadingLine loading={true} />
}
@@ -1,6 +1,6 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { zodResolver } from '@hookform/resolvers/zod'
import { Elements, useStripe } from '@stripe/react-stripe-js'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe, PaymentIntentResult } from '@stripe/stripe-js'
import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
@@ -35,10 +35,10 @@ import {
Form_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
LoadingLine,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import PaymentMethodSelection from './Subscription/PaymentMethodSelection'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
@@ -331,34 +331,3 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
</div>
)
}
const PaymentConfirmation = ({
paymentIntentSecret,
onPaymentIntentConfirm,
onLoadingChange,
paymentMethodId,
}: {
paymentIntentSecret: string
paymentMethodId: string
onPaymentIntentConfirm: (response: PaymentIntentResult) => void
onLoadingChange: (loading: boolean) => void
}) => {
const stripe = useStripe()
useEffect(() => {
if (stripe && paymentIntentSecret) {
onLoadingChange(true)
stripe!
.confirmCardPayment(paymentIntentSecret, { payment_method: paymentMethodId })
.then((res) => {
onPaymentIntentConfirm(res)
onLoadingChange(false)
})
.catch((err) => {
onLoadingChange(false)
})
}
}, [paymentIntentSecret, stripe])
return <LoadingLine loading={true} />
}
@@ -1,11 +1,11 @@
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import type { PaymentMethod } from '@stripe/stripe-js'
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
import type { PaymentIntentResult, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
import _ from 'lodash'
import { Edit2, ExternalLink, HelpCircle } from 'lucide-react'
import { ExternalLink, HelpCircle } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { parseAsString, useQueryStates } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { z } from 'zod'
@@ -16,9 +16,7 @@ import { useOrganizationCreateMutation } from 'data/organizations/organization-c
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
import { getURL } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from 'lib/constants'
import {
Button,
Input,
@@ -37,6 +35,13 @@ import {
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { BillingCustomerDataNewOrgDialog } from '../BillingSettings/BillingCustomerData/BillingCustomerDataNewOrgDialog'
import { FormCustomerData } from '../BillingSettings/BillingCustomerData/useBillingCustomerDataForm'
import { useConfirmPendingSubscriptionChangeMutation } from 'data/subscriptions/org-subscription-confirm-pending-change'
import { loadStripe } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
import { SetupIntentResponse } from 'data/stripe/setup-intent-mutation'
import { useProfile } from 'lib/profile'
import { PaymentConfirmation } from 'components/interfaces/Billing/Payment/PaymentConfirmation'
import { getURL } from 'lib/helpers'
const ORG_KIND_TYPES = {
PERSONAL: 'Personal',
@@ -59,6 +64,8 @@ const ORG_SIZE_DEFAULT = '1'
interface NewOrgFormProps {
onPaymentMethodReset: () => void
setupIntent?: SetupIntentResponse
onPlanSelected: (plan: string) => void
}
const formSchema = z.object({
@@ -79,17 +86,18 @@ const formSchema = z.object({
type FormState = z.infer<typeof formSchema>
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
/**
* No org selected yet, create a new one
* [Joshen] Need to refactor to use Form_Shadcn here
*/
const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOrgFormProps) => {
const router = useRouter()
const user = useProfile()
const { data: organizations, isSuccess } = useOrganizationsQuery()
const { data: projects } = useProjectsQuery()
const stripe = useStripe()
const elements = useElements()
const { resolvedTheme } = useTheme()
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
@@ -107,6 +115,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [customerData, setCustomerData] = useState<FormCustomerData | null>(null)
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
() =>
({
clientSecret: setupIntent ? setupIntent.client_secret! : '',
appearance: {
theme: resolvedTheme?.includes('dark') ? 'night' : 'flat',
labels: 'floating',
},
...(setupIntent?.pending_subscription_flow_enabled_for_creation === true
? { paymentMethodCreation: 'manual' }
: {}),
}) as const,
[setupIntent, resolvedTheme]
)
const [formState, setFormState] = useState<FormState>({
plan: 'FREE',
name: '',
@@ -118,7 +141,6 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [searchParams] = useQueryStates({
returnTo: parseAsString.withDefault(''),
auth_id: parseAsString.withDefault(''),
token: parseAsString.withDefault(''),
})
const updateForm = (key: keyof FormState, value: unknown) => {
@@ -132,7 +154,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
if (typeof name === 'string') updateForm('name', name)
if (typeof kind === 'string') updateForm('kind', kind)
if (typeof plan === 'string') updateForm('plan', plan)
if (typeof plan === 'string') {
updateForm('plan', plan)
onPlanSelected(plan)
}
if (typeof size === 'string') updateForm('size', size)
if (typeof spend_cap === 'string') updateForm('spend_cap', spend_cap === 'true')
}, [router.isReady])
@@ -147,26 +172,16 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
const [newOrgLoading, setNewOrgLoading] = useState(false)
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false)
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
const [paymentIntentSecret, setPaymentIntentSecret] = useState<string | null>(null)
const { mutate: createOrganization } = useOrganizationCreateMutation({
onSuccess: async (org) => {
const prefilledProjectName = user.profile?.username
? user.profile.username + `'s Project`
: 'My Project'
if (searchParams.returnTo && searchParams.auth_id) {
router.push(
`${searchParams.returnTo}?auth_id=${searchParams.auth_id}${
searchParams.token && `&token=${searchParams.token}&org=${org.name}`
}`,
undefined,
{
shallow: false,
}
)
if ('pending_payment_intent_secret' in org && org.pending_payment_intent_secret) {
setPaymentIntentSecret(org.pending_payment_intent_secret)
} else {
router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
onOrganizationCreated(org as { slug: string })
}
},
onError: (data) => {
@@ -176,11 +191,59 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
},
})
const { mutate: confirmPendingSubscriptionChange } = useConfirmPendingSubscriptionChangeMutation({
onSuccess: (data) => {
if (data && 'slug' in data) {
onOrganizationCreated({ slug: data.slug })
}
},
})
const paymentIntentConfirmed = async (paymentIntentConfirmation: PaymentIntentResult) => {
// Reset payment intent secret to ensure another attempt works as expected
setPaymentIntentSecret('')
if (paymentIntentConfirmation.paymentIntent?.status === 'succeeded') {
await confirmPendingSubscriptionChange({
payment_intent_id: paymentIntentConfirmation.paymentIntent.id,
name: formState.name,
kind: formState.kind,
size: formState.size,
})
} else {
// If the payment intent is not successful, we reset the payment method and show an error
toast.error(`Could not confirm payment. Please try again or use a different card.`)
resetPaymentMethod()
setNewOrgLoading(false)
}
}
const onOrganizationCreated = (org: { slug: string }) => {
const prefilledProjectName = user.profile?.username
? user.profile.username + `'s Project`
: 'My Project'
if (searchParams.returnTo && searchParams.auth_id) {
router.push(`${searchParams.returnTo}?auth_id=${searchParams.auth_id}`, undefined, {
shallow: false,
})
} else {
router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
}
}
function validateOrgName(name: any) {
const value = name ? name.trim() : ''
return value.length >= 1
}
const stripeOptionsConfirm = useMemo(() => {
return {
clientSecret: paymentIntentSecret,
appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' },
} as StripeElementsOptions
}, [paymentIntentSecret, resolvedTheme])
async function createOrg(paymentMethodId?: string) {
const dbTier = formState.plan === 'PRO' && !formState.spend_cap ? 'PAYG' : formState.plan
@@ -201,35 +264,21 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
})
}
const handleSubmit = async () => {
if (!stripe || !elements) {
return console.error('Stripe.js has not loaded')
}
const paymentRef = useRef<{ createPaymentMethod: () => Promise<any> }>(null)
const handleSubmit = async () => {
setNewOrgLoading(true)
if (formState.plan === 'FREE') {
await createOrg()
} else if (!paymentMethod) {
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: {
return_url: `${getURL()}/new`,
expand: ['payment_method'],
},
})
if (error || !setupIntent.payment_method) {
toast.error(error?.message ?? ' Failed to save card details')
const paymentMethod = await paymentRef.current?.createPaymentMethod()
if (paymentMethod) {
setPaymentMethod(paymentMethod)
createOrg(paymentMethod.id)
} else {
setNewOrgLoading(false)
return
}
const paymentMethodFromSetup = setupIntent.payment_method as PaymentMethod
setPaymentMethod(paymentMethodFromSetup)
createOrg(paymentMethodFromSetup.id)
} else {
createOrg(paymentMethod.id)
}
@@ -269,7 +318,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
<div key="panel-footer" className="flex w-full items-center justify-between">
<Button
type="default"
disabled={newOrgLoading}
disabled={newOrgLoading || paymentConfirmationLoading}
onClick={() => {
if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
else router.push('/organizations')
@@ -416,7 +465,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
<div className="col-span-2">
<Select_Shadcn_
value={formState.plan}
onValueChange={(value) => updateForm('plan', value)}
onValueChange={(value) => {
updateForm('plan', value)
onPlanSelected(value)
}}
>
<SelectTrigger_Shadcn_ id="plan" className="w-full">
<SelectValue_Shadcn_ />
@@ -496,41 +548,16 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
</Panel.Content>
)}
{formState.plan !== 'FREE' && (
{setupIntent && formState.plan !== 'FREE' && (
<Panel.Content>
{paymentMethod?.card !== undefined ? (
<div key={paymentMethod.id} className="flex items-center justify-between">
<div className="flex items-center space-x-8">
<img
alt="Card"
src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
.replace(' ', '-')
.toLowerCase()}.png`}
width="32"
/>
<Input
readOnly
className="w-64"
size="small"
value={`•••• •••• •••• ${paymentMethod.card.last4}`}
/>
<p className="text-sm tabular-nums">
Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year}
</p>
</div>
<div>
<Button
type="outline"
icon={<Edit2 />}
onClick={() => resetPaymentMethod()}
disabled={newOrgLoading}
className="hover:border-muted"
/>
</div>
</div>
) : (
<PaymentElement />
)}
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
<Payment
ref={paymentRef}
pending_subscription_flow_enabled_for_creation={
setupIntent?.pending_subscription_flow_enabled_for_creation === true
}
/>
</Elements>
</Panel.Content>
)}
</Panel>
@@ -606,8 +633,88 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
})}
</ul>
</ConfirmationModal>
{stripePromise && paymentIntentSecret && paymentMethod && (
<Elements stripe={stripePromise} options={stripeOptionsConfirm}>
<PaymentConfirmation
paymentIntentSecret={paymentIntentSecret}
onPaymentIntentConfirm={(paymentIntentConfirmation) =>
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
paymentMethodId={paymentMethod.id}
onError={(err) => {
toast.error(`Failed to confirm payment: ${err.message}`)
setNewOrgLoading(false)
resetPaymentMethod()
}}
/>
</Elements>
)}
</form>
)
}
export default NewOrgForm
/**
* Set up as a separate component, as we need any component using stripe/elements to be wrapped in Elements.
*
* If Elements is on a higher level, we risk losing all form state in case a payment fails.
*/
const Payment = forwardRef(
(
{
pending_subscription_flow_enabled_for_creation,
}: { pending_subscription_flow_enabled_for_creation: boolean },
ref
) => {
const stripe = useStripe()
const elements = useElements()
const createPaymentMethod = async () => {
if (!stripe || !elements) return
await elements.submit()
if (pending_subscription_flow_enabled_for_creation) {
// To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
const { error, paymentMethod } = await stripe.createPaymentMethod({
elements,
})
if (error || paymentMethod == null) {
toast.error(error?.message ?? ' Failed to process card details')
return
}
return paymentMethod
} else {
const { error, setupIntent } = await stripe.confirmSetup({
elements,
redirect: 'if_required',
confirmParams: {
return_url: `${getURL()}/new`,
expand: ['payment_method'],
},
})
if (error || !setupIntent.payment_method) {
toast.error(error?.message ?? ' Failed to save card details')
return
}
return setupIntent.payment_method as PaymentMethod
}
}
useImperativeHandle(ref, () => ({
createPaymentMethod,
}))
return (
<Panel.Content>
<PaymentElement />
</Panel.Content>
)
}
)
Payment.displayName = 'Payment'
@@ -62,21 +62,24 @@ export const useOrganizationCreateMutation = ({
(vars) => createOrganization(vars),
{
async onSuccess(data, variables, context) {
// [Joshen] We're manually updating the query client here as the org's subscription is
// created async, and the invalidation will happen too quick where the GET organizations
// endpoint will error out with a 500 since the subscription isn't created yet.
queryClient.setQueriesData(
{
queryKey: organizationKeys.list(),
exact: true,
},
(prev: any) => {
if (!prev) return prev
return [...prev, castOrganizationResponseToOrganization(data)]
}
)
if (data && !('pending_payment_intent_secret' in data)) {
// [Joshen] We're manually updating the query client here as the org's subscription is
// created async, and the invalidation will happen too quick where the GET organizations
// endpoint will error out with a 500 since the subscription isn't created yet.
queryClient.setQueriesData(
{
queryKey: organizationKeys.list(),
exact: true,
},
(prev: any) => {
if (!prev) return prev
return [...prev, castOrganizationResponseToOrganization(data)]
}
)
await queryClient.invalidateQueries(permissionKeys.list())
}
await queryClient.invalidateQueries(permissionKeys.list())
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -10,6 +10,7 @@ type OrganizationProjectClaimVariables = {
}
async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) {
// @ts-expect-error the endpoint is hidden in the spec for now
const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', {
params: { path: { slug, token } },
})
@@ -1,7 +1,5 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { organizationKeys } from './keys'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import type { ResponseError } from 'types'
@@ -10,8 +8,42 @@ type OrganizationProjectClaimVariables = {
token: string
}
export type OrganizationProjectClaimResponse =
components['schemas']['OrganizationProjectClaimResponse']
// todo replace with API type once exposed again - components['schemas']['OrganizationProjectClaimResponse']
export type OrganizationProjectClaimResponse = {
created_at: string
/** Format: uuid */
created_by: string
expires_at: string
preview: {
errors: {
key: string
message: string
}[]
info: {
key: string
message: string
}[]
members_exceeding_free_project_limit: {
limit: number
name: string
}[]
/** @enum {string} */
source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise'
target_organization_eligible: boolean | null
target_organization_has_free_project_slots: boolean | null
/** @enum {string|null} */
target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null
valid: boolean
warnings: {
key: string
message: string
}[]
}
project: {
name: string
ref: string
}
}
async function getOrganizationProjectClaim(
{ slug, token }: OrganizationProjectClaimVariables,
@@ -19,13 +51,14 @@ async function getOrganizationProjectClaim(
) {
if (!slug || !token) throw new Error('Slug and token are required')
// @ts-expect-error the endpoint is hidden in the spec for now
const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, {
params: { path: { slug, token } },
signal,
})
if (error) handleError(error)
return data
return data as OrganizationProjectClaimResponse
}
export type OrganizationProjectClaimData = Awaited<ReturnType<typeof getOrganizationProjectClaim>>
@@ -0,0 +1,92 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { organizationKeys } from 'data/organizations/keys'
import { permissionKeys } from 'data/permissions/keys'
import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query'
import type { components } from 'api-types'
export type PendingSubscriptionChangeVariables = {
payment_intent_id: string
name: string
kind?: string
size?: string
}
export async function confirmPendingSubscriptionChange({
payment_intent_id,
name,
kind,
size,
}: PendingSubscriptionChangeVariables) {
const { data, error } = await post('/platform/organizations/confirm-subscription', {
body: {
payment_intent_id,
name,
kind,
size,
},
})
if (error) handleError(error)
return data
}
type PendingSubscriptionChangeData = Awaited<ReturnType<typeof confirmPendingSubscriptionChange>>
export const useConfirmPendingSubscriptionChangeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<
PendingSubscriptionChangeData,
ResponseError,
PendingSubscriptionChangeVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
PendingSubscriptionChangeData,
ResponseError,
PendingSubscriptionChangeVariables
>((vars) => confirmPendingSubscriptionChange(vars), {
async onSuccess(data, variables, context) {
// [Joshen] We're manually updating the query client here as the org's subscription is
// created async, and the invalidation will happen too quick where the GET organizations
// endpoint will error out with a 500 since the subscription isn't created yet.
queryClient.setQueriesData(
{
queryKey: organizationKeys.list(),
exact: true,
},
(prev: any) => {
if (!prev) return prev
return [
...prev,
castOrganizationResponseToOrganization(
data as components['schemas']['OrganizationResponse']
),
]
}
)
await queryClient.invalidateQueries(permissionKeys.list())
// todo replace plan in org
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to confirm payment: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+14 -20
View File
@@ -1,7 +1,4 @@
import HCaptcha from '@hcaptcha/react-hcaptcha'
import { Elements } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { useTheme } from 'next-themes'
import { useCallback, useEffect, useState } from 'react'
import { NewOrgForm } from 'components/interfaces/Organization'
@@ -9,24 +6,21 @@ import AppLayout from 'components/layouts/AppLayout/AppLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import WizardLayout from 'components/layouts/WizardLayout'
import { SetupIntentResponse, useSetupIntent } from 'data/stripe/setup-intent-mutation'
import { STRIPE_PUBLIC_KEY } from 'lib/constants'
import { useIsHCaptchaLoaded } from 'stores/hcaptcha-loaded-store'
import type { NextPageWithLayout } from 'types'
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
/**
* No org selected yet, create a new one
*/
const Wizard: NextPageWithLayout = () => {
const { resolvedTheme } = useTheme()
const [intent, setIntent] = useState<SetupIntentResponse>()
const captchaLoaded = useIsHCaptchaLoaded()
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
const [selectedPlan, setSelectedPlan] = useState<string | null>(null)
const { mutate: setupIntent } = useSetupIntent({ onSuccess: (res) => setIntent(res) })
const captchaRefCallback = useCallback((node: any) => {
@@ -37,16 +31,15 @@ const Wizard: NextPageWithLayout = () => {
if (!hcaptchaToken) return console.error('Hcaptcha token is required')
// Force a reload of Elements, necessary for Stripe
// Also mitigates card testing to some extent as we generate a new captcha token
setIntent(undefined)
setupIntent({ hcaptchaToken })
}
const options = {
clientSecret: intent ? intent.client_secret : '',
appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' },
} as const
const loadPaymentForm = async (force = false) => {
if (selectedPlan == null || selectedPlan === 'FREE') return
if (intent != null && !force) return
const loadPaymentForm = async () => {
if (captchaRef && captchaLoaded) {
let token = captchaToken
@@ -66,10 +59,11 @@ const Wizard: NextPageWithLayout = () => {
useEffect(() => {
loadPaymentForm()
}, [captchaRef, captchaLoaded])
}, [captchaRef, captchaLoaded, selectedPlan])
const resetSetupIntent = () => {
return loadPaymentForm()
setIntent(undefined)
return loadPaymentForm(true)
}
const onLocalCancel = () => {
@@ -96,11 +90,11 @@ const Wizard: NextPageWithLayout = () => {
}}
/>
{intent && (
<Elements stripe={stripePromise} options={options}>
<NewOrgForm onPaymentMethodReset={() => resetSetupIntent()} />
</Elements>
)}
<NewOrgForm
setupIntent={intent}
onPaymentMethodReset={() => resetSetupIntent()}
onPlanSelected={(plan) => setSelectedPlan(plan)}
/>
</>
)
}
+10 -229
View File
@@ -218,24 +218,6 @@ export interface paths {
patch?: never
trace?: never
}
'/v1/organizations/{slug}/project-claim/{token}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Gets project details for the specified organization and claim token */
get: operations['v1-get-organization-project-claim']
put?: never
/** Claims project for the specified organization */
post: operations['v1-claim-project-for-organization']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/v1/projects': {
parameters: {
query?: never
@@ -386,7 +368,7 @@ export interface paths {
/** Get project api keys */
get: operations['v1-get-project-api-keys']
put?: never
/** [Alpha] Creates a new API key for the project */
/** [Beta] Creates a new API key for the project */
post: operations['createApiKey']
delete?: never
options?: never
@@ -401,15 +383,15 @@ export interface paths {
path?: never
cookie?: never
}
/** [Alpha] Get API key */
/** [Beta] Get API key */
get: operations['getApiKey']
put?: never
post?: never
/** [Alpha] Deletes an API key for the project */
/** [Beta] Deletes an API key for the project */
delete: operations['deleteApiKey']
options?: never
head?: never
/** [Alpha] Updates an API key for the project */
/** [Beta] Updates an API key for the project */
patch: operations['updateApiKey']
trace?: never
}
@@ -476,25 +458,6 @@ export interface paths {
patch?: never
trace?: never
}
'/v1/projects/{ref}/claim-token': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Gets project claim token */
get: operations['v1-get-project-claim-token']
put?: never
/** Creates project claim token */
post: operations['v1-create-project-claim-token']
/** Revokes project claim token */
delete: operations['v1-delete-project-claim-token']
options?: never
head?: never
patch?: never
trace?: never
}
'/v1/projects/{ref}/config/auth': {
parameters: {
query?: never
@@ -1606,6 +1569,9 @@ export interface components {
external_zoom_client_id: string | null
external_zoom_enabled: boolean | null
external_zoom_secret: string | null
hook_before_user_created_enabled: boolean | null
hook_before_user_created_secrets: string | null
hook_before_user_created_uri: string | null
hook_custom_access_token_enabled: boolean | null
hook_custom_access_token_secrets: string | null
hook_custom_access_token_uri: string | null
@@ -1865,14 +1831,6 @@ export interface components {
CreateOrganizationV1: {
name: string
}
CreateProjectClaimTokenResponse: {
created_at: string
/** Format: uuid */
created_by: string
expires_at: string
token: string
token_alias: string
}
CreateProviderBody: {
attribute_mapping?: {
keys: {
@@ -2292,41 +2250,6 @@ export interface components {
/** @enum {string} */
token_type: 'Bearer'
}
OrganizationProjectClaimResponse: {
created_at: string
/** Format: uuid */
created_by: string
expires_at: string
preview: {
errors: {
key: string
message: string
}[]
info: {
key: string
message: string
}[]
members_exceeding_free_project_limit: {
limit: number
name: string
}[]
/** @enum {string} */
source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise'
target_organization_eligible: boolean | null
target_organization_has_free_project_slots: boolean | null
/** @enum {string|null} */
target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null
valid: boolean
warnings: {
key: string
message: string
}[]
}
project: {
name: string
ref: string
}
}
OrganizationResponseV1: {
id: string
name: string
@@ -2368,13 +2291,6 @@ export interface components {
jwt_secret?: string
max_rows: number
}
ProjectClaimTokenResponse: {
created_at: string
/** Format: uuid */
created_by: string
expires_at: string
token_alias: string
}
ProjectUpgradeEligibilityResponse: {
current_app_version: string
/** @enum {string} */
@@ -2668,6 +2584,9 @@ export interface components {
external_zoom_client_id?: string | null
external_zoom_enabled?: boolean | null
external_zoom_secret?: string | null
hook_before_user_created_enabled?: boolean | null
hook_before_user_created_secrets?: string | null
hook_before_user_created_uri?: string | null
hook_custom_access_token_enabled?: boolean | null
hook_custom_access_token_secrets?: string | null
hook_custom_access_token_uri?: string | null
@@ -3676,62 +3595,6 @@ export interface operations {
}
}
}
'v1-get-organization-project-claim': {
parameters: {
query?: never
header?: never
path: {
/** @description Organization slug */
slug: string
token: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['OrganizationProjectClaimResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-claim-project-for-organization': {
parameters: {
query?: never
header?: never
path: {
/** @description Organization slug */
slug: string
token: string
}
cookie?: never
}
requestBody?: never
responses: {
204: {
headers: {
[name: string]: unknown
}
content?: never
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-list-all-projects': {
parameters: {
query?: never
@@ -4370,88 +4233,6 @@ export interface operations {
}
}
}
'v1-get-project-claim-token': {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ProjectClaimTokenResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-create-project-claim-token': {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['CreateProjectClaimTokenResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-delete-project-claim-token': {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
204: {
headers: {
[name: string]: unknown
}
content?: never
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-get-auth-service-config': {
parameters: {
query?: never
+89 -6
View File
@@ -1496,6 +1496,23 @@ export interface paths {
patch?: never
trace?: never
}
'/platform/organizations/confirm-subscription': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Confirm subscription change and apply pending changes */
post: operations['OrganizationsController_confirmSubscription']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/platform/organizations/fly/{fly_organization_id}': {
parameters: {
query?: never
@@ -4234,6 +4251,12 @@ export interface components {
/** @default 0 */
recoveryTimeTarget?: number
}
ConfirmSubscriptionChangeBody: {
kind?: string
name: string
payment_intent_id: string
size?: string
}
CopyObjectBody: {
from: string
to: string
@@ -4521,6 +4544,31 @@ export interface components {
/** @enum {string} */
tier: 'tier_free' | 'tier_pro' | 'tier_payg' | 'tier_team' | 'tier_enterprise'
}
CreateOrganizationResponse:
| {
pending_payment_intent_secret: string | null
}
| {
billing_email: string | null
id: number
is_owner: boolean
name: string
opt_in_tags: string[]
plan: {
/** @enum {string} */
id: 'free' | 'pro' | 'team' | 'enterprise'
name: string
}
restriction_data: {
[key: string]: string
} | null
/** @enum {string|null} */
restriction_status: 'grace_period' | 'grace_period_over' | 'restricted' | null
slug: string
stripe_customer_id: string | null
subscription_id: string | null
usage_billing_enabled: boolean
}
CreatePipelineResponse: {
id: number
}
@@ -5622,6 +5670,9 @@ export interface components {
EXTERNAL_ZOOM_CLIENT_ID: string
EXTERNAL_ZOOM_ENABLED: boolean
EXTERNAL_ZOOM_SECRET: string
HOOK_BEFORE_USER_CREATED_ENABLED: boolean
HOOK_BEFORE_USER_CREATED_SECRETS: string
HOOK_BEFORE_USER_CREATED_URI: string
HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: boolean
HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: string
HOOK_CUSTOM_ACCESS_TOKEN_URI: string
@@ -7280,11 +7331,7 @@ export interface components {
}
SetupIntentResponse: {
client_secret: string
payment_method:
| string
| {
[key: string]: unknown
}
pending_subscription_flow_enabled_for_creation: boolean
}
SignedUrlResponse: {
signedUrl: string
@@ -7819,6 +7866,9 @@ export interface components {
EXTERNAL_ZOOM_CLIENT_ID?: string | null
EXTERNAL_ZOOM_ENABLED?: boolean | null
EXTERNAL_ZOOM_SECRET?: string | null
HOOK_BEFORE_USER_CREATED_ENABLED?: boolean | null
HOOK_BEFORE_USER_CREATED_SECRETS?: string | null
HOOK_BEFORE_USER_CREATED_URI?: string | null
HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean | null
HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string | null
HOOK_CUSTOM_ACCESS_TOKEN_URI?: string | null
@@ -7929,6 +7979,9 @@ export interface components {
URI_ALLOW_LIST?: string | null
}
UpdateGoTrueConfigHooksBody: {
HOOK_BEFORE_USER_CREATED_ENABLED?: boolean | null
HOOK_BEFORE_USER_CREATED_SECRETS?: string | null
HOOK_BEFORE_USER_CREATED_URI?: string | null
HOOK_CUSTOM_ACCESS_TOKEN_ENABLED?: boolean | null
HOOK_CUSTOM_ACCESS_TOKEN_SECRETS?: string | null
HOOK_CUSTOM_ACCESS_TOKEN_URI?: string | null
@@ -10151,7 +10204,7 @@ export interface operations {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['OrganizationResponse']
'application/json': components['schemas']['CreateOrganizationResponse']
}
}
/** @description Unexpected error creating an organization */
@@ -11984,6 +12037,36 @@ export interface operations {
}
}
}
OrganizationsController_confirmSubscription: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['ConfirmSubscriptionChangeBody']
}
}
responses: {
201: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['CreateOrganizationResponse']
}
}
/** @description Failed to confirm subscription changes */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
OrganizationsController_getOrganizationByFlyOrganizationId: {
parameters: {
query?: never