Files
supabase/apps/studio/pages/new/[slug].tsx
Alaister Young e7cac83c4a chore: limit regions for nimbus (#38747)
* chore: limit regions for nimbus

* fix logic

* Autoselect east US if cloud provider is nimbus

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2025-09-25 16:09:15 +08:00

1072 lines
46 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { debounce } from 'lodash'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { PopoverSeparator } from '@ui/components/shadcn/ui/popover'
import { components } from 'api-types'
import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import {
FreeProjectLimitWarning,
NotOrganizationOwnerWarning,
} from 'components/interfaces/Organization/NewProject'
import { OrgNotFound } from 'components/interfaces/Organization/OrgNotFound'
import { AdvancedConfiguration } from 'components/interfaces/ProjectCreation/AdvancedConfiguration'
import {
extractPostgresVersionDetails,
PostgresVersionSelector,
} from 'components/interfaces/ProjectCreation/PostgresVersionSelector'
import { SPECIAL_CHARS_REGEX } from 'components/interfaces/ProjectCreation/ProjectCreation.constants'
import { smartRegionToExactRegion } from 'components/interfaces/ProjectCreation/ProjectCreation.utils'
import { RegionSelector } from 'components/interfaces/ProjectCreation/RegionSelector'
import { SecurityOptions } from 'components/interfaces/ProjectCreation/SecurityOptions'
import { SpecialSymbolsCallout } from 'components/interfaces/ProjectCreation/SpecialSymbolsCallout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { WizardLayoutWithoutAuth } from 'components/layouts/WizardLayout'
import DisabledWarningDueToIncident from 'components/ui/DisabledWarningDueToIncident'
import { InlineLink } from 'components/ui/InlineLink'
import Panel from 'components/ui/Panel'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import PasswordStrengthBar from 'components/ui/PasswordStrengthBar'
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, instanceSizeSpecs } from 'data/projects/new-project.constants'
import {
ProjectCreateVariables,
useProjectCreateMutation,
} from 'data/projects/project-create-mutation'
import { useProjectsQuery } from 'data/projects/projects-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { withAuth } from 'hooks/misc/withAuth'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import {
AWS_REGIONS_DEFAULT,
DEFAULT_MINIMUM_PASSWORD_STRENGTH,
FLY_REGIONS_DEFAULT,
MANAGED_BY,
PROJECT_STATUS,
PROVIDERS,
useDefaultProvider,
} from 'lib/constants'
import passwordStrength from 'lib/password-strength'
import { generateStrongPassword } from 'lib/project'
import { AWS_REGIONS, type CloudProvider } from 'shared-data'
import type { NextPageWithLayout } from 'types'
import {
Badge,
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectGroup_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
const sizes: DesiredInstanceSize[] = ['micro', 'small', 'medium']
const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small']
const FormSchema = z.object({
organization: z.string({
required_error: 'Please select an organization',
}),
projectName: z
.string()
.trim()
.min(1, 'Please enter a project name.') // Required field check
.min(3, 'Project name must be at least 3 characters long.') // Minimum length check
.max(64, 'Project name must be no longer than 64 characters.'), // Maximum length check
postgresVersion: z.string({
required_error: 'Please enter a Postgres version.',
}),
dbRegion: z.string({
required_error: 'Please select a region.',
}),
cloudProvider: z.string({
required_error: 'Please select a cloud provider.',
}),
dbPassStrength: z.number(),
dbPass: z
.string({ required_error: 'Please enter a database password.' })
.min(1, 'Password is required.'),
instanceSize: z.string(),
dataApi: z.boolean(),
useApiSchema: z.boolean(),
postgresVersionSelection: z.string(),
useOrioleDb: z.boolean(),
})
export type CreateProjectForm = z.infer<typeof FormSchema>
const Wizard: NextPageWithLayout = () => {
const router = useRouter()
const { slug, projectName } = useParams()
const { data: currentOrg } = useSelectedOrganizationQuery()
const isFreePlan = currentOrg?.plan?.id === 'free'
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
)
const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config')
const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers'])
// 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 })
}
}
const { mutate: sendEvent } = useSendEventMutation()
const smartRegionEnabled = useFlag('enableSmartRegion')
const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate')
const showPostgresVersionSelector = useFlag('showPostgresVersionSelector')
const cloudProviderEnabled = useFlag('enableFlyCloudProvider')
const { data: membersExceededLimit } = useFreeProjectLimitCheckQuery(
{ slug },
{ enabled: isFreePlan }
)
const { data: approvedOAuthApps } = useAuthorizedAppsQuery(
{ slug },
{ enabled: !isFreePlan && slug !== '_' }
)
const hasOAuthApps = approvedOAuthApps && approvedOAuthApps.length > 0
const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
const [passwordStrengthWarning, setPasswordStrengthWarning] = useState('')
const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] =
useState(false)
const { data: organizations, isSuccess: isOrganizationsSuccess } = useOrganizationsQuery()
const isNotOnTeamOrEnterprisePlan = useMemo(
() => !['team', 'enterprise'].includes(currentOrg?.plan.id ?? ''),
[currentOrg]
)
const { data: allOverdueInvoices } = useOverdueInvoicesQuery({
enabled: isNotOnTeamOrEnterprisePlan,
})
const overdueInvoices = (allOverdueInvoices ?? []).filter(
(x) => x.organization_id === currentOrg?.id
)
const hasOutstandingInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan
const {
mutate: createProject,
isLoading: isCreatingNewProject,
isSuccess: isSuccessNewProject,
} = useProjectCreateMutation({
onSuccess: (res) => {
sendEvent({
action: 'project_creation_simple_version_submitted',
properties: {
instanceSize: form.getValues('instanceSize'),
},
groups: {
project: res.ref,
organization: res.organization_slug,
},
})
router.push(`/project/${res.ref}/building`)
},
})
const { data: allProjectsFromApi } = useProjectsQuery()
const [allProjects, setAllProjects] = useState<
components['schemas']['ProjectInfo'][] | undefined
>(undefined)
const organizationProjects =
allProjects?.filter(
(project) =>
project.organization_id === currentOrg?.id && project.status !== PROJECT_STATUS.INACTIVE
) ?? []
const defaultProvider = useDefaultProvider()
const { data: _defaultRegion, error: defaultRegionError } = useDefaultRegionQuery(
{
cloudProvider: PROVIDERS[defaultProvider].id,
},
{
enabled: !smartRegionEnabled,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: false,
refetchOnReconnect: false,
retry: false,
}
)
const { data: availableRegionsData, error: availableRegionsError } =
useOrganizationAvailableRegionsQuery(
{
slug: slug,
cloudProvider: PROVIDERS[defaultProvider].id,
},
{
enabled: smartRegionEnabled,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchInterval: false,
refetchOnReconnect: false,
}
)
const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError
const defaultRegion = smartRegionEnabled
? availableRegionsData?.recommendations.smartGroup.name
: defaultProvider === 'AWS_NIMBUS'
? AWS_REGIONS.EAST_US.displayName
: _defaultRegion
const { can: isAdmin } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects')
const isInvalidSlug = isOrganizationsSuccess && currentOrg === undefined
const orgNotFound = isOrganizationsSuccess && (organizations?.length ?? 0) > 0 && isInvalidSlug
const isEmptyOrganizations = (organizations?.length ?? 0) <= 0 && isOrganizationsSuccess
const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0
const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
const freePlanWithExceedingLimits = isFreePlan && hasMembersExceedingFreeTierLimit
const isManagedByVercel = currentOrg?.managed_by === 'vercel-marketplace'
const canCreateProject =
isAdmin && !freePlanWithExceedingLimits && !isManagedByVercel && !hasOutstandingInvoices
const delayedCheckPasswordStrength = useRef(
debounce((value) => checkPasswordStrength(value), 300)
).current
async function checkPasswordStrength(value: any) {
const { message, warning, strength } = await passwordStrength(value)
form.setValue('dbPassStrength', strength)
form.trigger('dbPassStrength')
form.trigger('dbPass')
setPasswordStrengthWarning(warning)
setPasswordStrengthMessage(message)
}
FormSchema.superRefine(({ dbPassStrength }, refinementContext) => {
if (dbPassStrength < DEFAULT_MINIMUM_PASSWORD_STRENGTH) {
refinementContext.addIssue({
code: z.ZodIssueCode.custom,
path: ['dbPass'],
message: passwordStrengthWarning || 'Password not secure enough',
})
}
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
mode: 'onChange',
defaultValues: {
organization: slug,
projectName: projectName || '',
postgresVersion: '',
cloudProvider: PROVIDERS[defaultProvider].id,
dbPass: '',
dbPassStrength: 0,
dbRegion: defaultRegion || undefined,
instanceSize: sizes[0],
dataApi: true,
useApiSchema: false,
postgresVersionSelection: '',
useOrioleDb: false,
},
})
const { instanceSize, cloudProvider, dbRegion, organization } = form.watch()
const dbRegionExact = smartRegionToExactRegion(dbRegion)
const availableOrioleVersion = useAvailableOrioleImageVersion(
{
cloudProvider: cloudProvider as CloudProvider,
dbRegion: smartRegionEnabled ? dbRegionExact : dbRegion,
organizationSlug: organization,
},
{ enabled: currentOrg !== null && !isManagedByVercel }
)
// [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now
const monthlyComputeCosts =
// current project costs
organizationProjects.reduce(
(prev, acc) => prev + monthlyInstancePrice(acc.infra_compute_size),
0
) +
// selected compute size
monthlyInstancePrice(instanceSize) -
// compute credits
10
// [Refactor] DB Password could be a common component used in multiple pages with repeated logic
function generatePassword() {
const password = generateStrongPassword()
form.setValue('dbPass', password)
delayedCheckPasswordStrength(password)
}
const onSubmitWithComputeCostsConfirmation = async (values: z.infer<typeof FormSchema>) => {
const launchingLargerInstance =
values.instanceSize &&
!sizesWithNoCostConfirmationRequired.includes(values.instanceSize as DesiredInstanceSize)
if (additionalMonthlySpend > 0 && (hasOAuthApps || launchingLargerInstance)) {
sendEvent({
action: 'project_creation_simple_version_confirm_modal_opened',
properties: {
instanceSize: values.instanceSize,
},
groups: {
organization: currentOrg?.slug ?? 'Unknown',
},
})
setIsComputeCostsConfirmationModalVisible(true)
} else {
await onSubmit(values)
}
}
const onSubmit = async (values: z.infer<typeof FormSchema>) => {
if (!currentOrg) return console.error('Unable to retrieve current organization')
const {
cloudProvider,
projectName,
dbPass,
dbRegion,
postgresVersion,
instanceSize,
dataApi,
useApiSchema,
postgresVersionSelection,
useOrioleDb,
} = values
if (useOrioleDb && !availableOrioleVersion) {
return toast.error('No available OrioleDB image found, only Postgres is available')
}
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
const data: ProjectCreateVariables = {
dbPass,
cloudProvider,
organizationSlug: currentOrg.slug,
name: projectName,
// 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: !dataApi ? false : useApiSchema,
postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine,
releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel,
...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }),
}
if (postgresVersion) {
if (!postgresVersion.match(/1[2-9]\..*/)) {
toast.error(
`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`
)
}
data['customSupabaseRequest'] = {
ami: { search_tags: { 'tag:postgresVersion': postgresVersion } },
}
}
createProject(data)
}
useEffect(() => {
// Only set once to ensure compute credits dont change while project is being created
if (allProjectsFromApi && !allProjects) {
setAllProjects(allProjectsFromApi.projects)
}
}, [allProjectsFromApi, allProjects, setAllProjects])
useEffect(() => {
// Handle no org: redirect to new org route
if (isEmptyOrganizations) {
router.push(`/new`)
}
}, [isEmptyOrganizations, router])
useEffect(() => {
// [Joshen] Cause slug depends on router which doesnt load immediately on render
// While the form data does load immediately
if (slug && slug !== '_') form.setValue('organization', slug)
if (projectName) form.setValue('projectName', projectName || '')
}, [slug])
useEffect(() => {
if (form.getValues('dbRegion') === undefined && defaultRegion) {
form.setValue('dbRegion', defaultRegion)
}
}, [defaultRegion])
useEffect(() => {
if (regionError) {
form.setValue('dbRegion', PROVIDERS[defaultProvider].default_region.displayName)
}
}, [regionError])
const availableComputeCredits = organizationProjects.length === 0 ? 10 : 0
const additionalMonthlySpend = isFreePlan
? 0
: instanceSizeSpecs[instanceSize as DesiredInstanceSize]!.priceMonthly - availableComputeCredits
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation)}>
<Panel
loading={!isOrganizationsSuccess}
title={
<div key="panel-title">
<h3>Create a new project</h3>
<p className="text-sm text-foreground-lighter">
Your project will have its own dedicated instance and full Postgres database.
<br />
An API will be set up so you can easily interact with your new database.
<br />
</p>
</div>
}
footer={
<div key="panel-footer" className="grid grid-cols-12 w-full gap-4 items-center">
<div className="col-span-4">
{!isFreePlan &&
!projectCreationDisabled &&
canCreateProject &&
additionalMonthlySpend > 0 && (
<div className="flex justify-between text-sm">
<span>Additional costs</span>
<div className="text-brand flex gap-1 items-center font-mono font-medium">
<span>${additionalMonthlySpend}/m</span>
<InfoTooltip side="top" className="max-w-[450px] p-0">
<div className="p-4 text-sm text-foreground-light space-y-1">
<p>
Each project includes a dedicated Postgres instance running on its own
server. You are charged for the{' '}
<InlineLink href="https://supabase.com/docs/guides/platform/billing-on-supabase">
Compute resource
</InlineLink>{' '}
of that server, independent of your database usage.
</p>
{monthlyComputeCosts > 0 && (
<p>
Compute costs are applied on top of your subscription plan costs.
</p>
)}
</div>
<Table className="mt-2">
<TableHeader className="[&_th]:h-7">
<TableRow className="py-2">
<TableHead className="w-[170px]">Project</TableHead>
<TableHead>Compute Size</TableHead>
<TableHead className="text-right">Monthly Costs</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-2">
{organizationProjects.map((project) => (
<TableRow key={project.ref} className="text-foreground-light">
<TableCell className="w-[170px] truncate">
{project.name}
</TableCell>
<TableCell className="text-center">
{instanceLabel(project.infra_compute_size)}
</TableCell>
<TableCell className="text-right">
${monthlyInstancePrice(project.infra_compute_size)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell className="w-[170px] flex gap-2">
<span className="truncate">
{form.getValues('projectName')
? form.getValues('projectName')
: 'New project'}
</span>
<Badge size={'small'} variant={'default'}>
NEW
</Badge>
</TableCell>
<TableCell className="text-center">
{instanceLabel(instanceSize)}
</TableCell>
<TableCell className="text-right">
${monthlyInstancePrice(instanceSize)}
</TableCell>
</TableRow>
</TableBody>
</Table>
<PopoverSeparator />
<Table>
<TableHeader className="[&_th]:h-7">
<TableRow>
<TableHead colSpan={2}>Compute Credits</TableHead>
<TableHead colSpan={1} className="text-right">
-$10
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-2">
<TableRow className="text-foreground">
<TableCell colSpan={2}>
Total Monthly Compute Costs
{/**
* API currently doesnt output replica information on the projects list endpoint. Until then, we cannot correctly calculate the costs including RRs.
* Will be adjusted in the future [kevin]
*/}
{organizationProjects.length > 0 && (
<p className="text-xs text-foreground-lighter">
Excluding Read replicas
</p>
)}
</TableCell>
<TableCell colSpan={1} className="text-right">
${monthlyComputeCosts}
</TableCell>
</TableRow>
</TableBody>
</Table>
</InfoTooltip>
</div>
</div>
)}
</div>
<div className="flex items-end col-span-8 space-x-2 ml-auto">
<Button
type="default"
disabled={isCreatingNewProject || isSuccessNewProject}
onClick={() => {
if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
else router.push('/organizations')
}}
>
Cancel
</Button>
<Button
htmlType="submit"
loading={isCreatingNewProject || isSuccessNewProject}
disabled={!canCreateProject || isCreatingNewProject || isSuccessNewProject}
>
Create new project
</Button>
</div>
</div>
}
>
<>
{projectCreationDisabled ? (
<Panel.Content className="pb-8">
<DisabledWarningDueToIncident title="Project creation is currently disabled" />
</Panel.Content>
) : (
<div className="divide-y divide-border-muted">
<Panel.Content className={['space-y-4'].join(' ')}>
{isAdmin && !isInvalidSlug && (
<FormField_Shadcn_
control={form.control}
name="organization"
render={({ field }) => (
<FormItemLayout label="Organization" layout="horizontal">
{(organizations?.length ?? 0) > 0 && (
<Select_Shadcn_
onValueChange={(slug) => {
field.onChange(slug)
router.push(`/new/${slug}`)
}}
value={field.value}
defaultValue={field.value}
>
<FormControl_Shadcn_>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select an organization" />
</SelectTrigger_Shadcn_>
</FormControl_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{organizations?.map((x) => (
<SelectItem_Shadcn_
key={x.id}
value={x.slug}
className="flex justify-between"
>
<span className="mr-2">{x.name}</span>
<Badge>{x.plan.name}</Badge>
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)}
</FormItemLayout>
)}
/>
)}
{isOrganizationsSuccess && !isAdmin && !orgNotFound && (
<NotOrganizationOwnerWarning slug={slug} />
)}
{orgNotFound && <OrgNotFound slug={slug} />}
</Panel.Content>
{canCreateProject && (
<>
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="projectName"
render={({ field }) => (
<FormItemLayout label="Project name" layout="horizontal">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Project name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
{cloudProviderEnabled && showNonProdFields && (
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="cloudProvider"
render={({ field }) => (
<FormItemLayout label="Cloud provider" layout="horizontal">
<Select_Shadcn_
onValueChange={(value) => {
field.onChange(value)
form.setValue(
'dbRegion',
value === 'FLY'
? FLY_REGIONS_DEFAULT.displayName
: AWS_REGIONS_DEFAULT.displayName
)
}}
defaultValue={field.value}
>
<FormControl_Shadcn_>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a cloud provider" />
</SelectTrigger_Shadcn_>
</FormControl_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{Object.values(PROVIDERS)
.filter(
(provider) =>
validCloudProviders?.includes(provider.id) ?? true
)
.map((providerObj) => {
const label = providerObj['name']
const value = providerObj['id']
return (
<SelectItem_Shadcn_ key={value} value={value}>
{label}
</SelectItem_Shadcn_>
)
})}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
)}
{currentOrg?.plan && currentOrg?.plan.id !== 'free' && (
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="instanceSize"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label={
<div className="flex flex-col gap-y-4">
<span>Compute size</span>
<div className="flex flex-col gap-y-2">
<Link
target="_blank"
rel="noopener noreferrer"
href="https://supabase.com/docs/guides/platform/compute-add-ons"
>
<div className="flex items-center space-x-2 opacity-75 hover:opacity-100 transition">
<p className="text-sm m-0">Compute add-ons</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
<Link
target="_blank"
rel="noopener noreferrer"
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
>
<div className="flex items-center space-x-2 opacity-75 hover:opacity-100 transition">
<p className="text-sm m-0">Compute billing</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
</div>
</div>
}
description={
<>
<p>
The size for your dedicated database. You can change this later.
</p>
</>
}
>
<Select_Shadcn_
value={field.value}
onValueChange={(value) => field.onChange(value)}
>
<SelectTrigger_Shadcn_ className="[&_.instance-details]:hidden">
<SelectValue_Shadcn_ placeholder="Select a compute size" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{sizes
.filter((option) =>
instanceSizeSpecs[option].cloud_providers.includes(
form.getValues('cloudProvider') as CloudProvider
)
)
.map((option) => {
return (
<SelectItem_Shadcn_ key={option} value={option}>
<div className="flex flex-row i gap-2">
<div className="text-center w-[80px]">
<Badge
variant={option === 'micro' ? 'default' : 'brand'}
className="rounded-md w-16 text-center flex justify-center font-mono uppercase"
>
{instanceSizeSpecs[option].label}
</Badge>
</div>
<div className="text-sm">
<span className="text-foreground">
{instanceSizeSpecs[option].ram} RAM /{' '}
{instanceSizeSpecs[option].cpu}{' '}
{getCloudProviderArchitecture(
form.getValues('cloudProvider') as CloudProvider
)}{' '}
CPU
</span>
<p
className="text-xs text-foreground-light instance-details"
translate="no"
>
${instanceSizeSpecs[option].priceHourly}/hour (~$
{instanceSizeSpecs[option].priceMonthly}/month)
</p>
</div>
</div>
</SelectItem_Shadcn_>
)
})}
<SelectItem_Shadcn_
key={'disabled'}
value={'disabled'}
disabled
>
<div className="flex items-center justify-center w-full">
<span>Larger instance sizes available after creation</span>
</div>
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
)}
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="dbPass"
render={({ field }) => {
const hasSpecialCharacters =
field.value.length > 0 && !field.value.match(SPECIAL_CHARS_REGEX)
return (
<FormItemLayout
label="Database password"
layout="horizontal"
description={
<>
{hasSpecialCharacters && <SpecialSymbolsCallout />}
<PasswordStrengthBar
passwordStrengthScore={form.getValues('dbPassStrength')}
password={field.value}
passwordStrengthMessage={passwordStrengthMessage}
generateStrongPassword={generatePassword}
/>
</>
}
>
<FormControl_Shadcn_>
<Input
copy={field.value.length > 0}
type="password"
placeholder="Type in a strong password"
{...field}
autoComplete="off"
onChange={async (event) => {
field.onChange(event)
form.trigger('dbPassStrength')
const value = event.target.value
if (event.target.value === '') {
await form.setValue('dbPassStrength', 0)
await form.trigger('dbPass')
} else {
await delayedCheckPasswordStrength(value)
}
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)
}}
/>
</Panel.Content>
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="dbRegion"
render={({ field }) => (
<RegionSelector
field={field}
form={form}
cloudProvider={form.getValues('cloudProvider') as CloudProvider}
/>
)}
/>
</Panel.Content>
{showPostgresVersionSelector && (
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="postgresVersionSelection"
render={({ field }) => (
<PostgresVersionSelector
field={field}
form={form}
cloudProvider={form.getValues('cloudProvider') as CloudProvider}
organizationSlug={slug}
dbRegion={form.getValues('dbRegion')}
/>
)}
/>
</Panel.Content>
)}
{showNonProdFields && (
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="postgresVersion"
render={({ field }) => (
<FormItemLayout
label="Custom Postgres version"
layout="horizontal"
description="Specify a custom version of Postgres (defaults to the latest). This is only applicable for local/staging projects."
>
<FormControl_Shadcn_>
<Input_Shadcn_
placeholder="Postgres version"
{...field}
autoComplete="off"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
)}
<SecurityOptions form={form} />
{showAdvancedConfig && !!availableOrioleVersion && (
<AdvancedConfiguration form={form} />
)}
</>
)}
{freePlanWithExceedingLimits ? (
isAdmin &&
slug && (
<Panel.Content>
<FreeProjectLimitWarning
membersExceededLimit={membersExceededLimit || []}
orgSlug={slug}
/>
</Panel.Content>
)
) : isManagedByVercel ? (
<Panel.Content>
<PartnerManagedResource
managedBy={MANAGED_BY.VERCEL_MARKETPLACE}
resource="Projects"
cta={{
installationId: currentOrg?.partner_id,
message: 'Visit Vercel to create a project',
}}
/>
</Panel.Content>
) : 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 type="default">
<Link href={`/org/${slug}/billing#invoices`}>View invoices</Link>
</Button>
</div>
</div>
}
/>
</Panel.Content>
) : null}
</div>
)}
</>
</Panel>
<ConfirmationModal
size="large"
loading={false}
visible={isComputeCostsConfirmationModalVisible}
title="Confirm compute costs"
confirmLabel="I understand"
onCancel={() => setIsComputeCostsConfirmationModalVisible(false)}
onConfirm={async () => {
const values = form.getValues()
await onSubmit(values)
setIsComputeCostsConfirmationModalVisible(false)
}}
variant={'warning'}
>
<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="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
className="underline"
>
Compute Costs
</Link>{' '}
are non-refundable.
</p>
</div>
</ConfirmationModal>
</form>
</Form_Shadcn_>
)
}
/**
* When launching new projects, they only get assigned a compute size once successfully launched,
* this might assume wrong compute size, but only for projects being rapidly launched after one another on non-default compute sizes.
*
* Needs to be in the API in the future [kevin]
*/
const monthlyInstancePrice = (instance: string | undefined): number => {
return instanceSizeSpecs[instance as DesiredInstanceSize]?.priceMonthly || 10
}
const instanceLabel = (instance: string | undefined): string => {
return instanceSizeSpecs[instance as DesiredInstanceSize]?.label || 'Micro'
}
const PageLayout = withAuth(({ children }: PropsWithChildren) => {
return <WizardLayoutWithoutAuth>{children}</WizardLayoutWithoutAuth>
})
Wizard.getLayout = (page) => (
<DefaultLayout headerTitle="New project">
<PageLayout>{page}</PageLayout>
</DefaultLayout>
)
export default Wizard