mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
HA (Multigres) projects are free during Alpha, but the project creation form still presented the forced large compute as a real charge. The footer now shows **$0/m** for HA projects, with the usual compute price struck through + a "Free during Alpha" note in the cost-breakdown tooltip and the compute size dropdown. Follows the pattern from #49249. Addresses [MUL-1336](https://linear.app/supabase/issue/MUL-1336/bug-when-creating-new-projects). <img width="688" height="167" alt="Screenshot 2026-08-21 at 5 27 39 PM" src="https://github.com/user-attachments/assets/804b9243-77ae-4961-9083-5e1290734d3a" /> <img width="503" height="202" alt="Screenshot 2026-08-21 at 5 27 32 PM" src="https://github.com/user-attachments/assets/f217edd4-2204-4e5e-87f8-f54974e3c6fa" /> **Changed:** - `ProjectCreationFooter`: "Additional costs" shows `$0/m` when HA is on; the tooltip gains a "High availability projects are free during Alpha for up to 2 projects." sentence; the New-project row's price renders struck through with a "Free during Alpha" sub-line; the HA project's compute is excluded from "Total Monthly Compute Costs" (clamped at 0 so credits can't produce a negative total — a no-op for non-HA since spend already floors above zero) - `ComputeSizeSelector`: the per-option `$X/hour (~$Y/month)` line renders struck through with "Free during Alpha" beneath it when HA is on (both tagged `data-field="instance-details"` so the collapsed trigger keeps hiding them) - `ProjectCreationForm`: threads the watched `highAvailability` value into the footer The "Confirm compute costs" modal was already suppressed for HA by the existing `!values.highAvailability` guard — no change needed there. ## To test - On a paid org, open New Project and toggle High Availability on: the footer should read `$0/m` (brand green, no strikethrough), its ⓘ tooltip should show the HA sentence and the New row's `$110` struck through with "Free during Alpha", and the Total should exclude the $110; the compute dropdown's Large option should show its price struck through with "Free during Alpha" - Toggle HA off (and on/off a few times): all cost displays should revert exactly to normal — green real price, no strikethrough, no "Free during Alpha" anywhere outside the HA toggle's own description - With HA off and compute size Medium, submit: the "Confirm compute costs" modal should still appear as before (Cancel works) - Collapsed compute-size trigger should never show a price line or "Free during Alpha" in either state <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## New Features - High-availability project options now show compute pricing as free during Alpha. - Standard compute prices are displayed with a strikethrough alongside the Alpha-free notice. - Project cost summaries accurately show no additional compute charge for high-availability selections. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
832 lines
32 KiB
TypeScript
832 lines
32 KiB
TypeScript
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { acceptUntrustedSql, joinSqlFragments, untrustedSql } from '@supabase/pg-meta'
|
|
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import { useFeatureFlags, useFlag, useParams } from 'common'
|
|
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useForm, useFormState } from 'react-hook-form'
|
|
import { type CloudProvider } from 'shared-data'
|
|
import { toast } from 'sonner'
|
|
import { Button, cn, Form, useWatch } from 'ui'
|
|
import { Admonition } from 'ui-patterns/Admonition'
|
|
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
|
import { z } from 'zod'
|
|
|
|
import { AdvancedConfiguration } from './AdvancedConfiguration'
|
|
import { ComputeSizeSelector } from './ComputeSizeSelector'
|
|
import { DatabasePasswordInput } from './DatabasePasswordInput'
|
|
import { DataSeeding } from './DataSeeding'
|
|
import { DisabledWarningDueToIncident } from './DisabledWarningDueToIncident'
|
|
import { FreeProjectLimitWarning } from './FreeProjectLimitWarning'
|
|
import { HighAvailabilityInput } from './HighAvailabilityInput'
|
|
import { InternalOnlyConfiguration } from './InternalOnlyConfiguration'
|
|
import { OrganizationSelector } from './OrganizationSelector'
|
|
import { extractPostgresVersionDetails } from './PostgresVersionSelector'
|
|
import {
|
|
HIGH_AVAILABILITY_POSTGRES_ENGINE,
|
|
HIGH_AVAILABILITY_RELEASE_CHANNEL,
|
|
sizes,
|
|
} from './ProjectCreation.constants'
|
|
import { FormSchema } from './ProjectCreation.schema'
|
|
import {
|
|
getHighAvailabilityRegionCode,
|
|
instanceLabel,
|
|
monthlyInstancePrice,
|
|
resolveDefaultDbRegion,
|
|
smartRegionToExactRegion,
|
|
} from './ProjectCreation.utils'
|
|
import { ProjectCreationFooter } from './ProjectCreationFooter'
|
|
import { ProjectNameInput } from './ProjectNameInput'
|
|
import { RegionSelector } from './RegionSelector'
|
|
import { SecurityOptions } from './SecurityOptions'
|
|
import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants'
|
|
import {
|
|
GitHubRepositoryField,
|
|
useGitHubRepositoryOptions,
|
|
} from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField'
|
|
import Panel from '@/components/ui/Panel'
|
|
import { useAvailableOrioleImageVersion } from '@/data/config/project-creation-postgres-versions-query'
|
|
import { useOverdueInvoicesQuery } from '@/data/invoices/invoices-overdue-query'
|
|
import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query'
|
|
import { useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query'
|
|
import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query'
|
|
import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query'
|
|
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
|
|
import { DesiredInstanceSize } from '@/data/projects/new-project.constants'
|
|
import {
|
|
OrgProject,
|
|
useOrgProjectsInfiniteQuery,
|
|
} from '@/data/projects/org-projects-infinite-query'
|
|
import {
|
|
ProjectCreateVariables,
|
|
useProjectCreateMutation,
|
|
} from '@/data/projects/project-create-mutation'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import {
|
|
isInDataApiRevokeTreatment,
|
|
useDataApiRevokeOnCreateDefaultEnabled,
|
|
} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { usePHFlag } from '@/hooks/ui/useFlag'
|
|
import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from '@/lib/constants'
|
|
import { getInitialMigrationSQLFromGitHubRepo } from '@/lib/integration-utils'
|
|
import { useProfile } from '@/lib/profile'
|
|
import { trimSafeSqlFragment } from '@/lib/sql'
|
|
import { classifyApiError, classifyValidationError } from '@/lib/telemetry/funnel-errors'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { useTrackFunnelError } from '@/lib/telemetry/use-track-funnel-error'
|
|
|
|
const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small']
|
|
|
|
interface ProjectCreationFormProps {
|
|
isVercelIntegrationFlow?: boolean
|
|
onCreateSuccess?: (ref: string) => void
|
|
}
|
|
|
|
/**
|
|
* [Joshen] JFYI am only adding the `isVercelIntegrationFlow` flag to keep the existing
|
|
* behaviour for project creation via Vercel integration as similar to keep current state
|
|
* for now, what it controls if `true`:
|
|
* - Disables organization selection
|
|
* - Hides the following:
|
|
* - "Internal configuration" section
|
|
* - "GitHub repository" field
|
|
* - "Free project info" at the bottom
|
|
* - Cancel closes the popup window instead of navigating into Studio
|
|
* - Shows the following:
|
|
* - "Data seeding" section
|
|
* - When embedded in the Vercel interstitial, flattens Panel chrome so the shared
|
|
* form fields sit inside InterstitialLayout without a nested card
|
|
* Eventually we could looking into reducing the differences more, e.g having data seeding
|
|
* for both ways, and showing GitHub repository field for Vercel integration
|
|
*/
|
|
export const ProjectCreationForm = ({
|
|
isVercelIntegrationFlow = false,
|
|
onCreateSuccess,
|
|
}: ProjectCreationFormProps) => {
|
|
const track = useTrack()
|
|
const router = useRouter()
|
|
const { profile } = useProfile()
|
|
const { slug, projectName, externalId } = useParams()
|
|
const trackFunnelError = useTrackFunnelError()
|
|
const defaultProvider = useDefaultProvider()
|
|
|
|
const surface = isVercelIntegrationFlow ? 'vercel' : 'main'
|
|
|
|
const { data: currentOrg } = useSelectedOrganizationQuery()
|
|
const isFreePlan = currentOrg?.plan?.id === 'free'
|
|
const canChooseInstanceSize = !isFreePlan
|
|
|
|
const { lastVisitedOrganization } = useLastVisitedOrganization()
|
|
const { can: isAdmin } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects')
|
|
const { can: canCreateGitHubConnection } = useAsyncCheckPermissions(
|
|
PermissionAction.CREATE,
|
|
'integrations.github_connections'
|
|
)
|
|
const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config')
|
|
const { hasAccess: hasAccessToGitHubIntegration } = useCheckEntitlements(
|
|
'integrations.github_connections'
|
|
)
|
|
|
|
const { hasLoaded: flagsLoaded } = useFeatureFlags()
|
|
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
|
|
const showInternalOnlyConfiguration =
|
|
useFlag('newProjectInternalOnlyConfiguration') && !isVercelIntegrationFlow
|
|
|
|
// Read the raw flag for telemetry — coerce-undefined-to-false would record false for
|
|
// users whose flags haven't loaded yet. The raw value preserves undefined (omitted from
|
|
// PostHog) so we only record an actual value (boolean true/false, or a variant string
|
|
// like 'test'/'control' post-multivariate migration) once the flag has resolved.
|
|
const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean | string>(
|
|
'dataApiRevokeOnCreateDefault'
|
|
)
|
|
const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
|
|
|
|
const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '')
|
|
|
|
const [allProjects, setAllProjects] = useState<OrgProject[] | undefined>(undefined)
|
|
const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] =
|
|
useState(false)
|
|
const [projectCreationError, setProjectCreationError] = useState<string>()
|
|
|
|
const form = useForm<z.infer<typeof FormSchema>>({
|
|
resolver: zodResolver(FormSchema),
|
|
mode: 'onChange',
|
|
defaultValues: {
|
|
organization: slug,
|
|
projectName: projectName || '',
|
|
highAvailability: false,
|
|
postgresVersion: '',
|
|
instanceType: '',
|
|
cloudProvider: PROVIDERS[defaultProvider].id,
|
|
dbPass: '',
|
|
dbPassStrength: 0,
|
|
dbPassStrengthMessage: '',
|
|
dbRegion: undefined,
|
|
githubRepositoryId: '',
|
|
githubInstallationId: undefined,
|
|
githubRepositoryName: '',
|
|
instanceSize: canChooseInstanceSize ? sizes[0] : undefined,
|
|
dataApi: true,
|
|
dataApiDefaultPrivileges: !isDataApiRevokeOnCreateDefault,
|
|
enableRlsEventTrigger: false,
|
|
postgresVersionSelection: '',
|
|
useOrioleDb: false,
|
|
shouldRunMigrations: true,
|
|
},
|
|
})
|
|
const { getFieldState, resetField, setValue } = form
|
|
const {
|
|
instanceSize: watchedInstanceSize,
|
|
cloudProvider,
|
|
dbRegion,
|
|
githubRepositoryName,
|
|
organization,
|
|
projectName: watchedProjectName,
|
|
highAvailability,
|
|
} = useWatch({ control: form.control })
|
|
const { dirtyFields } = useFormState(form)
|
|
const isDbRegionDirty = dirtyFields.dbRegion
|
|
const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS'
|
|
const highAvailabilityRegionCode = getHighAvailabilityRegionCode()
|
|
|
|
// Read dirty state during render rather than depending on form.formState in the
|
|
// effect — form.formState is a Proxy that gets a new reference every render, which
|
|
// would re-fire this effect after each setValue and trigger an infinite loop.
|
|
const isDataApiDefaultPrivilegesDirty = getFieldState(
|
|
'dataApiDefaultPrivileges',
|
|
form.formState
|
|
).isDirty
|
|
|
|
// [Charis] Since the form is updated in a useEffect, there is an edge case
|
|
// when switching from free to paid, where canChooseInstanceSize is true for
|
|
// an in-between render, but watchedInstanceSize is still undefined from the
|
|
// form state carried over from the free plan. To avoid this, we set a
|
|
// default instance size in this case.
|
|
const instanceSize = canChooseInstanceSize ? (watchedInstanceSize ?? sizes[0]) : undefined
|
|
const { data: membersExceededLimit = [] } = useFreeProjectLimitCheckQuery(
|
|
{ slug },
|
|
{ enabled: isFreePlan }
|
|
)
|
|
const hasMembersExceedingFreeTierLimit = membersExceededLimit.length > 0
|
|
const freePlanWithExceedingLimits = isFreePlan && hasMembersExceedingFreeTierLimit
|
|
|
|
const { data: organizations = [], isSuccess: isOrganizationsSuccess } = useOrganizationsQuery()
|
|
const isEmptyOrganizations = isOrganizationsSuccess && organizations.length <= 0
|
|
|
|
const { data: approvedOAuthApps = [] } = useAuthorizedAppsQuery(
|
|
{ slug },
|
|
{ enabled: !isFreePlan && slug !== '_' }
|
|
)
|
|
const hasOAuthApps = approvedOAuthApps.length > 0
|
|
|
|
const { data: allOverdueInvoices = [] } = useOverdueInvoicesQuery({
|
|
enabled: isNotOnHigherPlan,
|
|
})
|
|
const overdueInvoices = allOverdueInvoices.filter((x) => x.organization_id === currentOrg?.id)
|
|
const hasOutstandingInvoices = isNotOnHigherPlan && overdueInvoices.length > 0
|
|
|
|
const { data: orgProjectsFromApi } = useOrgProjectsInfiniteQuery({ slug: currentOrg?.slug })
|
|
const allOrgProjects = useMemo(
|
|
() => orgProjectsFromApi?.pages.flatMap((page) => page.projects),
|
|
[orgProjectsFromApi?.pages]
|
|
)
|
|
const organizationProjects =
|
|
allProjects?.filter((project) => project.status !== PROJECT_STATUS.INACTIVE) ?? []
|
|
const availableComputeCredits = organizationProjects.length === 0 ? 10 : 0
|
|
const additionalMonthlySpend = isFreePlan
|
|
? 0
|
|
: monthlyInstancePrice(instanceSize) - availableComputeCredits
|
|
|
|
const selectedCloudProvider = cloudProvider as CloudProvider
|
|
const { data: autoDefaultRegion, error: defaultRegionError } = useDefaultRegionQuery(
|
|
{
|
|
cloudProvider: selectedCloudProvider,
|
|
},
|
|
{
|
|
enabled: flagsLoaded && !smartRegionEnabled,
|
|
refetchOnMount: false,
|
|
refetchOnWindowFocus: false,
|
|
refetchInterval: false,
|
|
refetchOnReconnect: false,
|
|
retry: false,
|
|
}
|
|
)
|
|
|
|
const { data: availableRegionsData, error: availableRegionsError } =
|
|
useOrganizationAvailableRegionsQuery(
|
|
{
|
|
slug: slug,
|
|
cloudProvider: PROVIDERS[cloudProvider as CloudProvider].id,
|
|
desiredInstanceSize: instanceSize as DesiredInstanceSize,
|
|
},
|
|
{
|
|
enabled: flagsLoaded && smartRegionEnabled,
|
|
refetchOnMount: false,
|
|
refetchOnWindowFocus: false,
|
|
refetchInterval: false,
|
|
refetchOnReconnect: false,
|
|
}
|
|
)
|
|
|
|
const highAvailabilityRegion =
|
|
highAvailability && highAvailabilityRegionCode !== undefined
|
|
? availableRegionsData?.all.specific.find(
|
|
(region) => region.code === highAvailabilityRegionCode
|
|
)
|
|
: undefined
|
|
const recommendedSmartRegion = smartRegionEnabled
|
|
? availableRegionsData?.recommendations.smartGroup.name
|
|
: ''
|
|
|
|
const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName
|
|
const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError
|
|
const defaultRegion = resolveDefaultDbRegion({
|
|
cloudProvider: selectedCloudProvider,
|
|
isHighAvailabilityRestricted:
|
|
highAvailability === true && highAvailabilityRegionCode !== undefined,
|
|
highAvailabilityRegionName: highAvailabilityRegion?.name,
|
|
isSmartRegionEnabled: smartRegionEnabled,
|
|
recommendedSmartRegion,
|
|
autoDefaultRegion,
|
|
fixedDefaultRegion,
|
|
})
|
|
|
|
const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices
|
|
const canConfigureGitHubOnCreate =
|
|
canCreateProject && hasAccessToGitHubIntegration && canCreateGitHubConnection
|
|
|
|
const dbRegionExact = smartRegionToExactRegion(dbRegion ?? '')
|
|
|
|
const availableOrioleVersion = useAvailableOrioleImageVersion(
|
|
{
|
|
cloudProvider: cloudProvider as CloudProvider,
|
|
dbRegion: smartRegionEnabled ? dbRegionExact : (dbRegion ?? ''),
|
|
organizationSlug: organization,
|
|
},
|
|
{ enabled: currentOrg !== null }
|
|
)
|
|
|
|
const userPrimaryEmail = profile?.primary_email?.toLowerCase()
|
|
const isUserAtFreeProjectLimit = userPrimaryEmail
|
|
? membersExceededLimit.some(
|
|
(member) => member.primary_email?.toLowerCase() === userPrimaryEmail
|
|
)
|
|
: false
|
|
const shouldShowFreeProjectInfo =
|
|
!!currentOrg && !isFreePlan && !isUserAtFreeProjectLimit && !isVercelIntegrationFlow
|
|
const {
|
|
gitHubAuthorization,
|
|
githubRepos,
|
|
hasPartialResponseDueToSSO,
|
|
isLoading: isLoadingRepositoryOptions,
|
|
refetch: refetchRepositoryOptions,
|
|
} = useGitHubRepositoryOptions()
|
|
|
|
const {
|
|
mutate: createProject,
|
|
isPending: isCreatingNewProject,
|
|
isSuccess: isSuccessNewProject,
|
|
} = useProjectCreateMutation({
|
|
onSuccess: (res) => {
|
|
setProjectCreationError(undefined)
|
|
track(
|
|
'project_creation_simple_version_submitted',
|
|
{
|
|
surface,
|
|
instanceSize: form.getValues('instanceSize'),
|
|
enableRlsEventTrigger: form.getValues('enableRlsEventTrigger'),
|
|
dataApiEnabled: form.getValues('dataApi'),
|
|
dataApiDefaultPrivilegesGranted: form.getValues('dataApiDefaultPrivileges'),
|
|
useOrioleDb: form.getValues('useOrioleDb'),
|
|
...(dataApiRevokeOnCreateDefaultFlag !== undefined && {
|
|
dataApiRevokeOnCreateDefaultEnabled: dataApiRevokeOnCreateDefaultFlag,
|
|
}),
|
|
},
|
|
{
|
|
project: res.ref,
|
|
organization: res.organization_slug,
|
|
}
|
|
)
|
|
onCreateSuccess?.(res.ref)
|
|
if (surface === 'main') router.push(`/project/${res.ref}`)
|
|
},
|
|
onError: (error) => {
|
|
if (isVercelIntegrationFlow) {
|
|
setProjectCreationError(`Failed to create new project: ${error.message}`)
|
|
trackFunnelError('project_creation', classifyApiError('project_creation', error), 'form')
|
|
return
|
|
}
|
|
const toastId = toast.error(`Failed to create new project: ${error.message}`)
|
|
trackFunnelError(
|
|
'project_creation',
|
|
classifyApiError('project_creation', error),
|
|
'toast',
|
|
toastId
|
|
)
|
|
},
|
|
})
|
|
|
|
const onSubmitWithComputeCostsConfirmation = async (values: z.infer<typeof FormSchema>) => {
|
|
const launchingLargerInstance =
|
|
values.instanceSize &&
|
|
!sizesWithNoCostConfirmationRequired.includes(values.instanceSize as DesiredInstanceSize)
|
|
|
|
// High availability projects are free during Alpha, so the forced large compute
|
|
// doesn't incur the usual compute costs.
|
|
const requiresCostConfirmation =
|
|
!values.highAvailability &&
|
|
additionalMonthlySpend > 0 &&
|
|
(hasOAuthApps || launchingLargerInstance)
|
|
|
|
if (requiresCostConfirmation) {
|
|
track('project_creation_simple_version_confirm_modal_opened', {
|
|
instanceSize: values.instanceSize,
|
|
})
|
|
setIsComputeCostsConfirmationModalVisible(true)
|
|
} else {
|
|
await onSubmit(values)
|
|
}
|
|
}
|
|
|
|
const onSubmit = async (values: z.infer<typeof FormSchema>) => {
|
|
if (!currentOrg) return console.error('Unable to retrieve current organization')
|
|
setProjectCreationError(undefined)
|
|
|
|
const {
|
|
cloudProvider,
|
|
projectName,
|
|
highAvailability,
|
|
dbPass,
|
|
dbRegion,
|
|
postgresVersion,
|
|
instanceType,
|
|
instanceSize,
|
|
dataApi,
|
|
dataApiDefaultPrivileges,
|
|
enableRlsEventTrigger,
|
|
postgresVersionSelection,
|
|
useOrioleDb,
|
|
githubInstallationId,
|
|
githubRepositoryId,
|
|
shouldRunMigrations,
|
|
} = values
|
|
|
|
// HA projects never take a custom version — the API resolves the image from
|
|
// postgresEngine + releaseChannel.
|
|
const customPostgresVersion = highAvailability ? undefined : postgresVersion
|
|
|
|
if (customPostgresVersion && !customPostgresVersion.match(/1[2-9]\..*/)) {
|
|
const message =
|
|
'Invalid Postgres version, should start with a number between 12-19, a dot and additional characters, i.e. 15.2 or 15.2.0-3'
|
|
if (isVercelIntegrationFlow) {
|
|
setProjectCreationError(message)
|
|
return
|
|
}
|
|
return toast.error(message)
|
|
}
|
|
|
|
if (useOrioleDb && !availableOrioleVersion) {
|
|
const message = 'No available OrioleDB image found, only Postgres is available'
|
|
if (isVercelIntegrationFlow) {
|
|
setProjectCreationError(message)
|
|
trackFunnelError(
|
|
'project_creation',
|
|
{ errorCategory: 'validation', errorReason: 'oriole_unavailable' },
|
|
'form'
|
|
)
|
|
return
|
|
}
|
|
const toastId = toast.error(message)
|
|
trackFunnelError(
|
|
'project_creation',
|
|
{ errorCategory: 'validation', errorReason: 'oriole_unavailable' },
|
|
'toast',
|
|
toastId
|
|
)
|
|
return
|
|
}
|
|
|
|
const { postgresEngine, releaseChannel } =
|
|
extractPostgresVersionDetails(postgresVersionSelection)
|
|
|
|
const { smartGroup = [], specific = [] } = availableRegionsData?.all ?? {}
|
|
const selectedRegion = smartRegionEnabled
|
|
? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion))
|
|
: undefined
|
|
|
|
if (highAvailability && highAvailabilityRegionCode !== undefined && !selectedRegion) {
|
|
return toast.error(
|
|
`High Availability projects are not available in the required region (${highAvailabilityRegionCode})`
|
|
)
|
|
}
|
|
const parsedGitHubRepositoryId =
|
|
githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined
|
|
const shouldIncludeGitHubFields =
|
|
githubInstallationId !== undefined && Number.isFinite(parsedGitHubRepositoryId)
|
|
|
|
let dbSql = enableRlsEventTrigger ? AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL : undefined
|
|
if (isVercelIntegrationFlow && shouldRunMigrations && !!externalId) {
|
|
const id = toast.loading(`Fetching initial migrations from GitHub repository...`)
|
|
|
|
try {
|
|
const migrationSql = await getInitialMigrationSQLFromGitHubRepo(externalId)
|
|
if (migrationSql) {
|
|
const safeMigrationSql = trimSafeSqlFragment(
|
|
acceptUntrustedSql(untrustedSql(migrationSql))
|
|
)
|
|
dbSql = dbSql
|
|
? joinSqlFragments([trimSafeSqlFragment(dbSql), safeMigrationSql], ';\n')
|
|
: safeMigrationSql
|
|
toast.loading(`Migrations fetched! Creating project...`, { id })
|
|
} else {
|
|
toast.loading('No migrations found, creating project...')
|
|
}
|
|
} catch (error) {
|
|
toast.loading(
|
|
`Failed to fetch migrations: ${error instanceof Error ? error.message : ''}. Proceeding to create project...`,
|
|
{ id }
|
|
)
|
|
}
|
|
}
|
|
|
|
const data: ProjectCreateVariables = {
|
|
dbSql,
|
|
dbPass,
|
|
cloudProvider,
|
|
organizationSlug: currentOrg.slug,
|
|
name: projectName,
|
|
highAvailability,
|
|
// gets ignored due to org billing subscription anyway
|
|
dbPricingTierId: 'tier_free',
|
|
// only set the compute size on pro+ plans. Free plans always use micro (nano in the future) size.
|
|
dbInstanceSize: isFreePlan ? undefined : (instanceSize as DesiredInstanceSize),
|
|
dataApiExposedSchemas: !dataApi ? [] : undefined,
|
|
dataApiUseApiSchema: false,
|
|
dataApiRevokeDefaultPrivileges: dataApi && !dataApiDefaultPrivileges,
|
|
postgresEngine: highAvailability
|
|
? HIGH_AVAILABILITY_POSTGRES_ENGINE
|
|
: useOrioleDb
|
|
? availableOrioleVersion?.postgres_engine
|
|
: postgresEngine,
|
|
releaseChannel: highAvailability
|
|
? HIGH_AVAILABILITY_RELEASE_CHANNEL
|
|
: useOrioleDb
|
|
? availableOrioleVersion?.release_channel
|
|
: releaseChannel,
|
|
...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }),
|
|
...(shouldIncludeGitHubFields
|
|
? {
|
|
githubInstallationId,
|
|
githubRepositoryId: parsedGitHubRepositoryId,
|
|
}
|
|
: {}),
|
|
}
|
|
|
|
if (customPostgresVersion || instanceType) {
|
|
data['customSupabaseRequest'] = {
|
|
ami: {
|
|
...(customPostgresVersion && {
|
|
search_tags: { 'tag:postgresVersion': customPostgresVersion },
|
|
}),
|
|
...(instanceType && { instance_type: instanceType }),
|
|
},
|
|
}
|
|
}
|
|
|
|
createProject(data)
|
|
}
|
|
|
|
const hasTrackedFormExposed = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (hasTrackedFormExposed.current) return
|
|
if (!isOrganizationsSuccess || !canCreateProject || !currentOrg) return
|
|
hasTrackedFormExposed.current = true
|
|
track('project_creation_form_exposed', { surface })
|
|
}, [isOrganizationsSuccess, canCreateProject, currentOrg, track, surface])
|
|
|
|
useEffect(() => {
|
|
// Only set once to ensure compute credits dont change while project is being created
|
|
if (allOrgProjects && allOrgProjects.length > 0 && !allProjects) {
|
|
setAllProjects(allOrgProjects)
|
|
}
|
|
}, [allOrgProjects, allProjects, setAllProjects])
|
|
|
|
useEffect(() => {
|
|
// Handle no org: redirect to new org route
|
|
if (isEmptyOrganizations && !isVercelIntegrationFlow) {
|
|
router.push(`/new`)
|
|
}
|
|
}, [isEmptyOrganizations, isVercelIntegrationFlow, router])
|
|
|
|
useEffect(() => {
|
|
// [Joshen] Cause slug depends on router which doesnt load immediately on render
|
|
// While the form data does load immediately
|
|
if (slug && slug !== '_') setValue('organization', slug)
|
|
if (projectName) setValue('projectName', projectName || '')
|
|
}, [slug, setValue, projectName])
|
|
|
|
useEffect(() => {
|
|
if (!isDbRegionDirty && defaultRegion) {
|
|
setValue('dbRegion', defaultRegion)
|
|
}
|
|
}, [defaultRegion, isDbRegionDirty, setValue])
|
|
|
|
useEffect(() => {
|
|
if (regionError && fixedDefaultRegion) {
|
|
resetField('dbRegion', { defaultValue: fixedDefaultRegion })
|
|
}
|
|
}, [regionError, resetField, fixedDefaultRegion])
|
|
|
|
useEffect(() => {
|
|
if (watchedInstanceSize !== instanceSize) {
|
|
setValue('instanceSize', instanceSize, {
|
|
shouldDirty: false,
|
|
shouldValidate: false,
|
|
shouldTouch: false,
|
|
})
|
|
}
|
|
}, [instanceSize, watchedInstanceSize, setValue])
|
|
|
|
useEffect(() => {
|
|
if (!githubRepositoryName) return
|
|
if ((watchedProjectName ?? '').trim().length > 0) return
|
|
|
|
const repoName = githubRepositoryName.split('/').at(-1) ?? githubRepositoryName
|
|
setValue('projectName', repoName.trim(), {
|
|
shouldValidate: true,
|
|
})
|
|
}, [githubRepositoryName, watchedProjectName, setValue])
|
|
|
|
useEffect(() => {
|
|
if (dataApiRevokeOnCreateDefaultFlag === undefined) return
|
|
if (isDataApiDefaultPrivilegesDirty) return
|
|
setValue(
|
|
'dataApiDefaultPrivileges',
|
|
!isInDataApiRevokeTreatment(dataApiRevokeOnCreateDefaultFlag),
|
|
{
|
|
shouldDirty: false,
|
|
}
|
|
)
|
|
}, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue])
|
|
|
|
useEffect(() => {
|
|
// This is to make the database.new redirect work correctly. The database.new redirect should be set to supabase.com/dashboard/new/last-visited-org
|
|
if (slug === 'last-visited-org') {
|
|
if (lastVisitedOrganization) {
|
|
router.replace(`/new/${lastVisitedOrganization}`, undefined, { shallow: true })
|
|
} else {
|
|
router.replace(`/new/_`, undefined, { shallow: true })
|
|
}
|
|
}
|
|
}, [slug, lastVisitedOrganization, router])
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation, (errors) =>
|
|
trackFunnelError(
|
|
'project_creation',
|
|
classifyValidationError('project_creation', errors),
|
|
'form'
|
|
)
|
|
)}
|
|
>
|
|
<Panel
|
|
loading={!isOrganizationsSuccess}
|
|
noMargin={isVercelIntegrationFlow}
|
|
className={cn(
|
|
isVercelIntegrationFlow && 'border-0 shadow-none rounded-none bg-transparent'
|
|
)}
|
|
title={
|
|
!isVercelIntegrationFlow && (
|
|
<div key="panel-title">
|
|
<h3>Create a new project</h3>
|
|
<p className="text-sm text-foreground-lighter text-balance">
|
|
Your project will have its own dedicated instance and full Postgres database. An
|
|
API will be set up so you can easily interact with your new database.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
footer={
|
|
<ProjectCreationFooter
|
|
form={form}
|
|
canCreateProject={canCreateProject}
|
|
instanceSize={instanceSize}
|
|
highAvailability={highAvailability}
|
|
organizationProjects={organizationProjects}
|
|
isCreatingNewProject={isCreatingNewProject}
|
|
isSuccessNewProject={isSuccessNewProject}
|
|
cancelAction={isVercelIntegrationFlow ? 'close' : 'studio'}
|
|
/>
|
|
}
|
|
>
|
|
<>
|
|
{projectCreationDisabled ? (
|
|
<DisabledWarningDueToIncident title="Project creation is currently disabled" />
|
|
) : (
|
|
<div className="divide-y divide-border-border">
|
|
<OrganizationSelector
|
|
form={form}
|
|
disableOrganizationSelection={isVercelIntegrationFlow}
|
|
/>
|
|
|
|
{canCreateProject && (
|
|
<>
|
|
{!isVercelIntegrationFlow && canConfigureGitHubOnCreate && (
|
|
<Panel.Content>
|
|
<GitHubRepositoryField
|
|
form={form}
|
|
name="githubRepositoryId"
|
|
installationIdField="githubInstallationId"
|
|
repositoryNameField="githubRepositoryName"
|
|
label="GitHub (optional)"
|
|
description={
|
|
<>
|
|
Ideal for agent-first workflows. Update your schema in code and push
|
|
it to GitHub. Supabase deploys the changes.{' '}
|
|
<a
|
|
href="https://supabase.com/docs/guides/deployment/branching/github-integration"
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
className="text-link"
|
|
>
|
|
Learn more
|
|
</a>
|
|
</>
|
|
}
|
|
disabled={isCreatingNewProject}
|
|
repositories={githubRepos}
|
|
gitHubAuthorization={gitHubAuthorization}
|
|
hasPartialResponseDueToSSO={hasPartialResponseDueToSSO}
|
|
isLoading={isLoadingRepositoryOptions}
|
|
refetch={refetchRepositoryOptions}
|
|
onConnectClick={() => track('project_creation_github_connect_clicked')}
|
|
/>
|
|
</Panel.Content>
|
|
)}
|
|
<ProjectNameInput form={form} />
|
|
|
|
<HighAvailabilityInput
|
|
form={form}
|
|
highAvailabilityRegionName={highAvailabilityRegion?.name}
|
|
/>
|
|
|
|
{canChooseInstanceSize && <ComputeSizeSelector form={form} />}
|
|
|
|
<DatabasePasswordInput form={form} />
|
|
|
|
<RegionSelector
|
|
form={form}
|
|
instanceSize={instanceSize as DesiredInstanceSize}
|
|
/>
|
|
|
|
{isVercelIntegrationFlow && !!externalId && <DataSeeding form={form} />}
|
|
|
|
<SecurityOptions form={form} surface={surface} />
|
|
|
|
{showInternalOnlyConfiguration && <InternalOnlyConfiguration form={form} />}
|
|
|
|
{showAdvancedConfig &&
|
|
!!availableOrioleVersion &&
|
|
highAvailability !== true && <AdvancedConfiguration form={form} />}
|
|
|
|
{shouldShowFreeProjectInfo ? (
|
|
<Admonition
|
|
className="rounded-none border-0"
|
|
type="note"
|
|
title="Need a free project?"
|
|
description={
|
|
<p>
|
|
You can have up to 2 free projects across all organizations.{' '}
|
|
<Link className="underline text-foreground" href="/new">
|
|
Create a free organization
|
|
</Link>{' '}
|
|
to use them.
|
|
</p>
|
|
}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{freePlanWithExceedingLimits ? (
|
|
isAdmin &&
|
|
slug && (
|
|
<FreeProjectLimitWarning membersExceededLimit={membersExceededLimit || []} />
|
|
)
|
|
) : hasOutstandingInvoices ? (
|
|
<Panel.Content>
|
|
<Admonition
|
|
type="default"
|
|
title="Your organization has overdue invoices"
|
|
description={
|
|
<div className="space-y-3">
|
|
<p className="text-sm leading-normal">
|
|
Please resolve all outstanding invoices first before creating a new
|
|
project
|
|
</p>
|
|
|
|
<div>
|
|
<Button asChild variant="default">
|
|
<Link href={`/org/${slug}/billing#invoices`}>View invoices</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
/>
|
|
</Panel.Content>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{projectCreationError && (
|
|
<Panel.Content>
|
|
<p role="alert" className="text-sm text-destructive">
|
|
{projectCreationError}
|
|
</p>
|
|
</Panel.Content>
|
|
)}
|
|
</>
|
|
</Panel>
|
|
|
|
<ConfirmationModal
|
|
size="large"
|
|
variant="warning"
|
|
loading={isCreatingNewProject}
|
|
visible={isComputeCostsConfirmationModalVisible}
|
|
title="Confirm compute costs"
|
|
confirmLabel="I understand"
|
|
onCancel={() => setIsComputeCostsConfirmationModalVisible(false)}
|
|
onConfirm={async () => {
|
|
const values = form.getValues()
|
|
await onSubmit(values)
|
|
setIsComputeCostsConfirmationModalVisible(false)
|
|
}}
|
|
>
|
|
<div className="text-sm text-foreground-light space-y-1">
|
|
<p>
|
|
Launching a project on compute size "{instanceLabel(instanceSize)}" increases your
|
|
monthly costs by ${additionalMonthlySpend}, independent of how actively you use it. By
|
|
clicking "I understand", you agree to the additional costs.{' '}
|
|
<Link
|
|
href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`}
|
|
target="_blank"
|
|
className="underline"
|
|
>
|
|
Compute Costs
|
|
</Link>{' '}
|
|
are non-refundable.
|
|
</p>
|
|
</div>
|
|
</ConfirmationModal>
|
|
</form>
|
|
</Form>
|
|
)
|
|
}
|