From ff318d07f0d20cdda7ae9bd14fe03fc486a56951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Gr=C3=BCneberg?= Date: Fri, 13 Jun 2025 10:27:09 +0800 Subject: [PATCH] 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. --- .../Billing/Payment/PaymentConfirmation.tsx | 39 +++ .../BillingSettings/CreditTopUp.tsx | 35 +-- .../Organization/NewOrg/NewOrgForm.tsx | 275 ++++++++++++------ .../organization-create-mutation.ts | 31 +- .../organization-project-claim-mutation.ts | 1 + .../organization-project-claim-query.ts | 43 ++- ...org-subscription-confirm-pending-change.ts | 92 ++++++ apps/studio/pages/new/index.tsx | 34 +-- packages/api-types/types/api.d.ts | 239 +-------------- packages/api-types/types/platform.d.ts | 95 +++++- 10 files changed, 493 insertions(+), 391 deletions(-) create mode 100644 apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx create mode 100644 apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts diff --git a/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx new file mode 100644 index 00000000000..5b568e36437 --- /dev/null +++ b/apps/studio/components/interfaces/Billing/Payment/PaymentConfirmation.tsx @@ -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 +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx index eed17a4eb47..a4954c689e5 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx @@ -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 }) => { ) } - -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 -} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 2f16d232697..8d52683c54e 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -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 +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(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({ 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() + const [paymentConfirmationLoading, setPaymentConfirmationLoading] = useState(false) const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false) + const [paymentIntentSecret, setPaymentIntentSecret] = useState(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 }>(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) => {
- - ) : ( - - )} + + + )} @@ -606,8 +633,88 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => { })} + + {stripePromise && paymentIntentSecret && paymentMethod && ( + + + paymentIntentConfirmed(paymentIntentConfirmation) + } + onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)} + paymentMethodId={paymentMethod.id} + onError={(err) => { + toast.error(`Failed to confirm payment: ${err.message}`) + setNewOrgLoading(false) + resetPaymentMethod() + }} + /> + + )} ) } 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 ( + + + + ) + } +) + +Payment.displayName = 'Payment' diff --git a/apps/studio/data/organizations/organization-create-mutation.ts b/apps/studio/data/organizations/organization-create-mutation.ts index 4273e98b2d3..3c28c0a4941 100644 --- a/apps/studio/data/organizations/organization-create-mutation.ts +++ b/apps/studio/data/organizations/organization-create-mutation.ts @@ -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) { diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 16d42a31571..25b29c20ea4 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -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 } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index b02534661a2..f620074a041 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -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> diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts new file mode 100644 index 00000000000..ed6065771c8 --- /dev/null +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -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> + +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, + }) +} diff --git a/apps/studio/pages/new/index.tsx b/apps/studio/pages/new/index.tsx index 01395da9a8f..4c98990860f 100644 --- a/apps/studio/pages/new/index.tsx +++ b/apps/studio/pages/new/index.tsx @@ -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() const captchaLoaded = useIsHCaptchaLoaded() const [captchaToken, setCaptchaToken] = useState(null) const [captchaRef, setCaptchaRef] = useState(null) + const [selectedPlan, setSelectedPlan] = useState(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 && ( - - resetSetupIntent()} /> - - )} + resetSetupIntent()} + onPlanSelected={(plan) => setSelectedPlan(plan)} + /> ) } diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 9cd8b1d107a..e1ec2f4d4ba 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -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 diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 4b9aa895269..ff4237991c3 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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