mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: Redirect to create org when the user has no orgs to authorize (#30211)
* Add types to the stripe API calls. * Add a warning and redirect to new org if the user doesn't have an org. Refactor the new org page to keep the state in url. * Simplify the plans listing. * Revert some CSS changes.
This commit is contained in:
1 parent
6dabaa44fc
commit
06d95acca7
4 files changed
+85
-44
No files matched your search
@@ -7,7 +7,6 @@ import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import SpendCapModal from 'components/interfaces/Billing/SpendCapModal'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useOrganizationCreateMutation } from 'data/organizations/organization-create-mutation'
|
||||
@@ -15,9 +14,10 @@ import {
|
||||
invalidateOrganizationsQuery,
|
||||
useOrganizationsQuery,
|
||||
} from 'data/organizations/organizations-query'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { BASE_PATH, PRICING_TIER_LABELS_ORG } from 'lib/constants'
|
||||
import { getURL } from 'lib/helpers'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
|
||||
import { Button, Input, Listbox, Toggle } from 'ui'
|
||||
|
||||
const ORG_KIND_TYPES = {
|
||||
@@ -53,50 +53,50 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
const queryClient = useQueryClient()
|
||||
const { plan, name, kind, size, spend_cap } = useParams()
|
||||
const [state, setState] = useQueryStates({
|
||||
plan: parseAsStringLiteral(Object.keys(PRICING_TIER_LABELS_ORG)).withDefault('FREE'),
|
||||
name: parseAsString.withDefault(''),
|
||||
kind: parseAsStringLiteral(Object.keys(ORG_KIND_TYPES)).withDefault(ORG_KIND_DEFAULT),
|
||||
size: parseAsString.withDefault(ORG_SIZE_DEFAULT),
|
||||
spend_cap: parseAsBoolean.withDefault(true),
|
||||
returnTo: parseAsString.withDefault(''),
|
||||
auth_id: parseAsString.withDefault(''),
|
||||
})
|
||||
const {
|
||||
name: orgName,
|
||||
kind: orgKind,
|
||||
spend_cap: isSpendCapEnabled,
|
||||
plan: dbPricingTierKey,
|
||||
size: orgSize,
|
||||
returnTo,
|
||||
auth_id,
|
||||
} = state
|
||||
|
||||
const [orgName, setOrgName] = useState(name || '')
|
||||
const [orgKind, setOrgKind] = useState(
|
||||
kind && Object.keys(ORG_KIND_TYPES).includes(kind) ? kind : ORG_KIND_DEFAULT
|
||||
)
|
||||
const [orgSize, setOrgSize] = useState(size || ORG_SIZE_DEFAULT)
|
||||
// [Joshen] Separate loading state here as there's 2 async processes
|
||||
const [newOrgLoading, setNewOrgLoading] = useState(false)
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>()
|
||||
|
||||
// URL param support for passing plan
|
||||
const [dbPricingTierKey, setDbPricingTierKey] = useState(
|
||||
plan && ['free', 'team', 'pro'].includes(plan) ? plan.toUpperCase() : 'FREE'
|
||||
)
|
||||
|
||||
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
|
||||
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(spend_cap ? Boolean(spend_cap) : true)
|
||||
|
||||
useEffect(() => {
|
||||
const query: Record<string, string> = {}
|
||||
query.plan = dbPricingTierKey.toLowerCase()
|
||||
if (orgName) query.name = orgName
|
||||
if (orgKind) query.kind = orgKind
|
||||
if (orgSize) query.size = orgSize
|
||||
if (isSpendCapEnabled) query.spend_cap = isSpendCapEnabled.toString()
|
||||
|
||||
router.push({ query })
|
||||
}, [dbPricingTierKey, orgName, orgKind, orgSize, isSpendCapEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!orgName && organizations?.length === 0 && !user.isLoading) {
|
||||
const prefilledOrgName = user.profile?.username ? user.profile.username + `'s Org` : 'My Org'
|
||||
setOrgName(prefilledOrgName)
|
||||
setState({ ...state, name: prefilledOrgName })
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
const { mutate: createOrganization } = useOrganizationCreateMutation({
|
||||
onSuccess: async (org: any) => {
|
||||
onSuccess: async (org) => {
|
||||
await invalidateOrganizationsQuery(queryClient)
|
||||
const prefilledProjectName = user.profile?.username
|
||||
? user.profile.username + `'s Project`
|
||||
: 'My Project'
|
||||
router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
|
||||
|
||||
if (returnTo && auth_id) {
|
||||
router.push(`${returnTo}?auth_id=${auth_id}`, undefined, { shallow: false })
|
||||
} else {
|
||||
router.push(`/new/${org.slug}?projectName=${prefilledProjectName}`)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
resetPaymentMethod()
|
||||
@@ -110,19 +110,19 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
}
|
||||
|
||||
function onOrgNameChange(e: any) {
|
||||
setOrgName(e.target.value)
|
||||
setState({ ...state, name: e.target.value })
|
||||
}
|
||||
|
||||
function onOrgKindChange(value: any) {
|
||||
setOrgKind(value)
|
||||
setState({ ...state, kind: value })
|
||||
}
|
||||
|
||||
function onOrgSizeChange(value: any) {
|
||||
setOrgSize(value)
|
||||
setState({ ...state, size: value })
|
||||
}
|
||||
|
||||
function onDbPricingPlanChange(value: string) {
|
||||
setDbPricingTierKey(value)
|
||||
setState({ ...state, plan: value as any })
|
||||
}
|
||||
|
||||
async function createOrg(paymentMethodId?: string) {
|
||||
@@ -341,7 +341,7 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {
|
||||
</div>
|
||||
}
|
||||
checked={isSpendCapEnabled}
|
||||
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
|
||||
onChange={() => setState({ ...state, spend_cap: !isSpendCapEnabled })}
|
||||
descriptionText={
|
||||
<div>
|
||||
<p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
@@ -8,6 +9,8 @@ export type SetupIntentVariables = {
|
||||
hcaptchaToken: string
|
||||
}
|
||||
|
||||
export type SetupIntentResponse = components['schemas']['SetupIntentResponse']
|
||||
|
||||
export async function setupIntent({ hcaptchaToken }: SetupIntentVariables) {
|
||||
const { data, error } = await post('/platform/stripe/setup-intent', {
|
||||
body: { hcaptchaToken },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -14,7 +16,14 @@ import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorizati
|
||||
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Alert, Button, Listbox } from 'ui'
|
||||
import {
|
||||
Alert,
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
Listbox,
|
||||
} from 'ui'
|
||||
|
||||
// Need to handle if no organizations in account
|
||||
// Need to handle if not logged in yet state
|
||||
@@ -141,6 +150,15 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
let pathname = location.pathname
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH
|
||||
if (basePath) {
|
||||
pathname = pathname.replace(basePath, '')
|
||||
}
|
||||
|
||||
searchParams.set('returnTo', pathname)
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
header={<p>Authorize API access for {requester?.name}</p>}
|
||||
@@ -155,13 +173,21 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
<Button
|
||||
loading={isApproving}
|
||||
disabled={isDeclining || isExpired}
|
||||
onClick={onApproveRequest}
|
||||
>
|
||||
Authorize {requester?.name}
|
||||
</Button>
|
||||
{isLoadingOrganizations ? (
|
||||
<Button loading={isLoadingOrganizations}>Authorize {requester?.name}</Button>
|
||||
) : isSuccessOrganizations && organizations.length === 0 ? (
|
||||
<Link href={`/new?${searchParams.toString()}`}>
|
||||
<Button loading={isLoadingOrganizations}>Create an organization</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
loading={isApproving}
|
||||
disabled={isDeclining || isExpired}
|
||||
onClick={onApproveRequest}
|
||||
>
|
||||
Authorize {requester?.name}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -188,6 +214,18 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
</div>
|
||||
) : organizations?.length === 0 ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle_Shadcn_>
|
||||
Organization is needed for installing an integration
|
||||
</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="">
|
||||
Your account isn't associated with any organizations. To use this integration, it must
|
||||
be installed within an organization. You'll be redirected to create an organization
|
||||
first.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<Listbox
|
||||
label="Select an organization to grant API access to"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { NewOrgForm } from 'components/interfaces/Organization'
|
||||
import WizardLayout from 'components/layouts/WizardLayout'
|
||||
import { useSetupIntent } from 'data/stripe/setup-intent-mutation'
|
||||
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'
|
||||
@@ -19,7 +19,7 @@ const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
||||
const Wizard: NextPageWithLayout = () => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const [intent, setIntent] = useState<any>()
|
||||
const [intent, setIntent] = useState<SetupIntentResponse>()
|
||||
const captchaLoaded = useIsHCaptchaLoaded()
|
||||
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
||||
@@ -42,7 +42,7 @@ const Wizard: NextPageWithLayout = () => {
|
||||
const options = {
|
||||
clientSecret: intent ? intent.client_secret : '',
|
||||
appearance: { theme: resolvedTheme?.includes('dark') ? 'night' : 'flat', labels: 'floating' },
|
||||
} as any
|
||||
} as const
|
||||
|
||||
const loadPaymentForm = async () => {
|
||||
if (captchaRef && captchaLoaded) {
|
||||
|
||||
Reference in new issue
Block a user