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:
Ivan Vasilov authored and GitHub committed 2024-11-04 17:59:17 +01:00
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 },
+46 -8
View File
@@ -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"
+3 -3
View File
@@ -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) {