[slug].tsx678 lines · main
| 1 | // @ts-nocheck |
| 2 | import { zodResolver } from '@hookform/resolvers/zod' |
| 3 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 4 | import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common' |
| 5 | import Head from 'next/head' |
| 6 | import Link from 'next/link' |
| 7 | import { useRouter } from 'next/router' |
| 8 | import { PropsWithChildren, useEffect, useMemo, useState } from 'react' |
| 9 | import { useForm } from 'react-hook-form' |
| 10 | import { AWS_REGIONS, type CloudProvider } from 'shared-data' |
| 11 | import { toast } from 'sonner' |
| 12 | import { Button, Form, useWatch } from 'ui' |
| 13 | import { Admonition } from 'ui-patterns/admonition' |
| 14 | import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' |
| 15 | import { z } from 'zod' |
| 16 | |
| 17 | import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants' |
| 18 | import { AdvancedConfiguration } from '@/components/interfaces/ProjectCreation/AdvancedConfiguration' |
| 19 | import { ComputeSizeSelector } from '@/components/interfaces/ProjectCreation/ComputeSizeSelector' |
| 20 | import { DatabasePasswordInput } from '@/components/interfaces/ProjectCreation/DatabasePasswordInput' |
| 21 | import { DisabledWarningDueToIncident } from '@/components/interfaces/ProjectCreation/DisabledWarningDueToIncident' |
| 22 | import { FreeProjectLimitWarning } from '@/components/interfaces/ProjectCreation/FreeProjectLimitWarning' |
| 23 | import { InternalOnlyConfiguration } from '@/components/interfaces/ProjectCreation/InternalOnlyConfiguration' |
| 24 | import { OrganizationSelector } from '@/components/interfaces/ProjectCreation/OrganizationSelector' |
| 25 | import { extractPostgresVersionDetails } from '@/components/interfaces/ProjectCreation/PostgresVersionSelector' |
| 26 | import { sizes } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants' |
| 27 | import { FormSchema } from '@/components/interfaces/ProjectCreation/ProjectCreation.schema' |
| 28 | import { |
| 29 | instanceLabel, |
| 30 | smartRegionToExactRegion, |
| 31 | } from '@/components/interfaces/ProjectCreation/ProjectCreation.utils' |
| 32 | import { ProjectCreationFooter } from '@/components/interfaces/ProjectCreation/ProjectCreationFooter' |
| 33 | import { ProjectNameInput } from '@/components/interfaces/ProjectCreation/ProjectNameInput' |
| 34 | import { RegionSelector } from '@/components/interfaces/ProjectCreation/RegionSelector' |
| 35 | import { SecurityOptions } from '@/components/interfaces/ProjectCreation/SecurityOptions' |
| 36 | import { |
| 37 | GitHubRepositoryField, |
| 38 | useGitHubRepositoryOptions, |
| 39 | } from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField' |
| 40 | import DefaultLayout from '@/components/layouts/DefaultLayout' |
| 41 | import { WizardLayoutWithoutAuth } from '@/components/layouts/WizardLayout' |
| 42 | import Panel from '@/components/ui/Panel' |
| 43 | import { useAvailableOrioleImageVersion } from '@/data/config/project-creation-postgres-versions-query' |
| 44 | import { useOverdueInvoicesQuery } from '@/data/invoices/invoices-overdue-query' |
| 45 | import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query' |
| 46 | import { useAuthorizedAppsQuery } from '@/data/oauth/authorized-apps-query' |
| 47 | import { useFreeProjectLimitCheckQuery } from '@/data/organizations/free-project-limit-check-query' |
| 48 | import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query' |
| 49 | import { useOrganizationsQuery } from '@/data/organizations/organizations-query' |
| 50 | import { DesiredInstanceSize, instanceSizeSpecs } from '@/data/projects/new-project.constants' |
| 51 | import { |
| 52 | OrgProject, |
| 53 | useOrgProjectsInfiniteQuery, |
| 54 | } from '@/data/projects/org-projects-infinite-query' |
| 55 | import { |
| 56 | ProjectCreateVariables, |
| 57 | useProjectCreateMutation, |
| 58 | } from '@/data/projects/project-create-mutation' |
| 59 | import { useCustomContent } from '@/hooks/custom-content/useCustomContent' |
| 60 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 61 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 62 | import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' |
| 63 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 64 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 65 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 66 | import { withAuth } from '@/hooks/misc/withAuth' |
| 67 | import { usePHFlag } from '@/hooks/ui/useFlag' |
| 68 | import { DOCS_URL, PROJECT_STATUS, PROVIDERS, useDefaultProvider } from '@/lib/constants' |
| 69 | import { buildStudioPageTitle } from '@/lib/page-title' |
| 70 | import { useProfile } from '@/lib/profile' |
| 71 | import { useTrack } from '@/lib/telemetry/track' |
| 72 | import type { NextPageWithLayout } from '@/types' |
| 73 | |
| 74 | const sizesWithNoCostConfirmationRequired: DesiredInstanceSize[] = ['micro', 'small'] |
| 75 | |
| 76 | const Wizard: NextPageWithLayout = () => { |
| 77 | const track = useTrack() |
| 78 | const router = useRouter() |
| 79 | const { slug, projectName } = useParams() |
| 80 | const { appTitle } = useCustomContent(['app:title']) |
| 81 | const defaultProvider = useDefaultProvider() |
| 82 | const { profile } = useProfile() |
| 83 | const pageTitle = buildStudioPageTitle({ |
| 84 | section: 'New Project', |
| 85 | brand: appTitle || 'Briven', |
| 86 | }) |
| 87 | |
| 88 | const { data: currentOrg } = useSelectedOrganizationQuery() |
| 89 | const isFreePlan = currentOrg?.plan?.id === 'free' |
| 90 | const canChooseInstanceSize = !isFreePlan |
| 91 | |
| 92 | const [lastVisitedOrganization] = useLocalStorageQuery( |
| 93 | LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION, |
| 94 | '' |
| 95 | ) |
| 96 | const { can: isAdmin } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') |
| 97 | const { can: canCreateGitHubConnection } = useAsyncCheckPermissions( |
| 98 | PermissionAction.CREATE, |
| 99 | 'integrations.github_connections' |
| 100 | ) |
| 101 | const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config') |
| 102 | const { hasAccess: hasAccessToGitHubIntegration } = useCheckEntitlements( |
| 103 | 'integrations.github_connections' |
| 104 | ) |
| 105 | |
| 106 | const smartRegionEnabled = useFlag('enableSmartRegion') |
| 107 | const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') |
| 108 | const showInternalOnlyConfiguration = useFlag('newProjectInternalOnlyConfiguration') |
| 109 | |
| 110 | // Read the raw flag for telemetry — coerce-undefined-to-false would record false for |
| 111 | // users whose flags haven't loaded yet. The raw value preserves undefined (omitted from |
| 112 | // PostHog) so we only record true/false when the flag is resolved. |
| 113 | const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault') |
| 114 | const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled() |
| 115 | |
| 116 | const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(currentOrg?.plan.id ?? '') |
| 117 | |
| 118 | // 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 |
| 119 | if (slug === 'last-visited-org') { |
| 120 | if (lastVisitedOrganization) { |
| 121 | router.replace(`/new/${lastVisitedOrganization}`, undefined, { shallow: true }) |
| 122 | } else { |
| 123 | router.replace(`/new/_`, undefined, { shallow: true }) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | const [allProjects, setAllProjects] = useState<OrgProject[] | undefined>(undefined) |
| 128 | const [isComputeCostsConfirmationModalVisible, setIsComputeCostsConfirmationModalVisible] = |
| 129 | useState(false) |
| 130 | |
| 131 | const form = useForm<z.infer<typeof FormSchema>>({ |
| 132 | resolver: zodResolver(FormSchema as any), |
| 133 | mode: 'onChange', |
| 134 | defaultValues: { |
| 135 | organization: slug, |
| 136 | projectName: projectName || '', |
| 137 | highAvailability: false, |
| 138 | postgresVersion: '', |
| 139 | instanceType: '', |
| 140 | cloudProvider: PROVIDERS[defaultProvider].id, |
| 141 | dbPass: '', |
| 142 | dbPassStrength: 0, |
| 143 | dbPassStrengthMessage: '', |
| 144 | dbRegion: undefined, |
| 145 | githubRepositoryId: '', |
| 146 | githubInstallationId: undefined, |
| 147 | githubRepositoryName: '', |
| 148 | instanceSize: canChooseInstanceSize ? sizes[0] : undefined, |
| 149 | dataApi: true, |
| 150 | dataApiDefaultPrivileges: !isDataApiRevokeOnCreateDefault, |
| 151 | enableRlsEventTrigger: false, |
| 152 | postgresVersionSelection: '', |
| 153 | useOrioleDb: false, |
| 154 | }, |
| 155 | }) |
| 156 | const { getFieldState, resetField, setValue } = form |
| 157 | const { |
| 158 | instanceSize: watchedInstanceSize, |
| 159 | cloudProvider, |
| 160 | dbRegion, |
| 161 | githubRepositoryName, |
| 162 | organization, |
| 163 | projectName: watchedProjectName, |
| 164 | highAvailability, |
| 165 | } = useWatch({ control: form.control }) |
| 166 | |
| 167 | // Read dirty state during render rather than depending on form.formState in the |
| 168 | // effect — form.formState is a Proxy that gets a new reference every render, which |
| 169 | // would re-fire this effect after each setValue and trigger an infinite loop. |
| 170 | const isDataApiDefaultPrivilegesDirty = getFieldState( |
| 171 | 'dataApiDefaultPrivileges', |
| 172 | form.formState |
| 173 | ).isDirty |
| 174 | |
| 175 | useEffect(() => { |
| 176 | if (dataApiRevokeOnCreateDefaultFlag === undefined) return |
| 177 | if (isDataApiDefaultPrivilegesDirty) return |
| 178 | setValue('dataApiDefaultPrivileges', !dataApiRevokeOnCreateDefaultFlag, { |
| 179 | shouldDirty: false, |
| 180 | }) |
| 181 | }, [dataApiRevokeOnCreateDefaultFlag, isDataApiDefaultPrivilegesDirty, setValue]) |
| 182 | |
| 183 | // [Charis] Since the form is updated in a useEffect, there is an edge case |
| 184 | // when switching from free to paid, where canChooseInstanceSize is true for |
| 185 | // an in-between render, but watchedInstanceSize is still undefined from the |
| 186 | // form state carried over from the free plan. To avoid this, we set a |
| 187 | // default instance size in this case. |
| 188 | const instanceSize = canChooseInstanceSize ? (watchedInstanceSize ?? sizes[0]) : undefined |
| 189 | const { data: membersExceededLimit = [] } = useFreeProjectLimitCheckQuery( |
| 190 | { slug }, |
| 191 | { enabled: isFreePlan } |
| 192 | ) |
| 193 | const hasMembersExceedingFreeTierLimit = membersExceededLimit.length > 0 |
| 194 | const freePlanWithExceedingLimits = isFreePlan && hasMembersExceedingFreeTierLimit |
| 195 | |
| 196 | const { data: organizations = [], isSuccess: isOrganizationsSuccess } = useOrganizationsQuery() |
| 197 | const isEmptyOrganizations = isOrganizationsSuccess && organizations.length <= 0 |
| 198 | |
| 199 | const { data: approvedOAuthApps = [] } = useAuthorizedAppsQuery( |
| 200 | { slug }, |
| 201 | { enabled: !isFreePlan && slug !== '_' } |
| 202 | ) |
| 203 | const hasOAuthApps = approvedOAuthApps.length > 0 |
| 204 | |
| 205 | const { data: allOverdueInvoices = [] } = useOverdueInvoicesQuery({ |
| 206 | enabled: isNotOnHigherPlan, |
| 207 | }) |
| 208 | const overdueInvoices = allOverdueInvoices.filter((x) => x.organization_id === currentOrg?.id) |
| 209 | const hasOutstandingInvoices = isNotOnHigherPlan && overdueInvoices.length > 0 |
| 210 | |
| 211 | const { data: orgProjectsFromApi } = useOrgProjectsInfiniteQuery({ slug: currentOrg?.slug }) |
| 212 | const allOrgProjects = useMemo( |
| 213 | () => orgProjectsFromApi?.pages.flatMap((page) => page.projects), |
| 214 | [orgProjectsFromApi?.pages] |
| 215 | ) |
| 216 | const organizationProjects = |
| 217 | allProjects?.filter((project) => project.status !== PROJECT_STATUS.INACTIVE) ?? [] |
| 218 | const availableComputeCredits = organizationProjects.length === 0 ? 10 : 0 |
| 219 | const additionalMonthlySpend = isFreePlan |
| 220 | ? 0 |
| 221 | : instanceSizeSpecs[instanceSize as DesiredInstanceSize]!.priceMonthly - availableComputeCredits |
| 222 | |
| 223 | const { data: _defaultRegion, error: defaultRegionError } = useDefaultRegionQuery( |
| 224 | { |
| 225 | cloudProvider: PROVIDERS[defaultProvider].id, |
| 226 | }, |
| 227 | { |
| 228 | enabled: !smartRegionEnabled, |
| 229 | refetchOnMount: false, |
| 230 | refetchOnWindowFocus: false, |
| 231 | refetchInterval: false, |
| 232 | refetchOnReconnect: false, |
| 233 | retry: false, |
| 234 | } |
| 235 | ) |
| 236 | |
| 237 | const { data: availableRegionsData, error: availableRegionsError } = |
| 238 | useOrganizationAvailableRegionsQuery( |
| 239 | { |
| 240 | slug: slug, |
| 241 | cloudProvider: PROVIDERS[cloudProvider as CloudProvider].id, |
| 242 | desiredInstanceSize: instanceSize as DesiredInstanceSize, |
| 243 | }, |
| 244 | { |
| 245 | enabled: smartRegionEnabled, |
| 246 | refetchOnMount: false, |
| 247 | refetchOnWindowFocus: false, |
| 248 | refetchInterval: false, |
| 249 | refetchOnReconnect: false, |
| 250 | } |
| 251 | ) |
| 252 | const recommendedSmartRegion = smartRegionEnabled |
| 253 | ? availableRegionsData?.recommendations.smartGroup.name |
| 254 | : '' |
| 255 | |
| 256 | const regionError = |
| 257 | smartRegionEnabled && defaultProvider !== 'AWS_NIMBUS' |
| 258 | ? availableRegionsError |
| 259 | : defaultRegionError |
| 260 | const defaultRegion = |
| 261 | defaultProvider === 'AWS_NIMBUS' |
| 262 | ? AWS_REGIONS.EAST_US.displayName |
| 263 | : smartRegionEnabled |
| 264 | ? availableRegionsData?.recommendations.smartGroup.name |
| 265 | : _defaultRegion |
| 266 | |
| 267 | const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices |
| 268 | const canConfigureGitHubOnCreate = |
| 269 | canCreateProject && hasAccessToGitHubIntegration && canCreateGitHubConnection |
| 270 | |
| 271 | const dbRegionExact = smartRegionToExactRegion(dbRegion ?? '') |
| 272 | |
| 273 | const availableOrioleVersion = useAvailableOrioleImageVersion( |
| 274 | { |
| 275 | cloudProvider: cloudProvider as CloudProvider, |
| 276 | dbRegion: smartRegionEnabled ? dbRegionExact : (dbRegion ?? ''), |
| 277 | organizationSlug: organization, |
| 278 | }, |
| 279 | { enabled: currentOrg !== null } |
| 280 | ) |
| 281 | |
| 282 | const userPrimaryEmail = profile?.primary_email?.toLowerCase() |
| 283 | const isUserAtFreeProjectLimit = userPrimaryEmail |
| 284 | ? membersExceededLimit.some( |
| 285 | (member) => member.primary_email?.toLowerCase() === userPrimaryEmail |
| 286 | ) |
| 287 | : false |
| 288 | const shouldShowFreeProjectInfo = !!currentOrg && !isFreePlan && !isUserAtFreeProjectLimit |
| 289 | const { |
| 290 | gitHubAuthorization, |
| 291 | githubRepos, |
| 292 | hasPartialResponseDueToSSO, |
| 293 | isLoading: isLoadingRepositoryOptions, |
| 294 | refetch: refetchRepositoryOptions, |
| 295 | } = useGitHubRepositoryOptions() |
| 296 | |
| 297 | const { |
| 298 | mutate: createProject, |
| 299 | isPending: isCreatingNewProject, |
| 300 | isSuccess: isSuccessNewProject, |
| 301 | } = useProjectCreateMutation({ |
| 302 | onSuccess: (res) => { |
| 303 | track( |
| 304 | 'project_creation_simple_version_submitted', |
| 305 | { |
| 306 | surface: 'main', |
| 307 | instanceSize: form.getValues('instanceSize'), |
| 308 | enableRlsEventTrigger: form.getValues('enableRlsEventTrigger'), |
| 309 | dataApiEnabled: form.getValues('dataApi'), |
| 310 | dataApiDefaultPrivilegesGranted: form.getValues('dataApiDefaultPrivileges'), |
| 311 | useOrioleDb: form.getValues('useOrioleDb'), |
| 312 | ...(dataApiRevokeOnCreateDefaultFlag !== undefined && { |
| 313 | dataApiRevokeOnCreateDefaultEnabled: dataApiRevokeOnCreateDefaultFlag, |
| 314 | }), |
| 315 | }, |
| 316 | { |
| 317 | project: res.ref, |
| 318 | organization: res.organization_slug, |
| 319 | } |
| 320 | ) |
| 321 | router.push(`/project/${res.ref}`) |
| 322 | }, |
| 323 | }) |
| 324 | |
| 325 | const onSubmitWithComputeCostsConfirmation = async (values: z.infer<typeof FormSchema>) => { |
| 326 | const launchingLargerInstance = |
| 327 | values.instanceSize && |
| 328 | !sizesWithNoCostConfirmationRequired.includes(values.instanceSize as DesiredInstanceSize) |
| 329 | |
| 330 | if (additionalMonthlySpend > 0 && (hasOAuthApps || launchingLargerInstance)) { |
| 331 | track('project_creation_simple_version_confirm_modal_opened', { |
| 332 | instanceSize: values.instanceSize, |
| 333 | }) |
| 334 | setIsComputeCostsConfirmationModalVisible(true) |
| 335 | } else { |
| 336 | await onSubmit(values) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | const onSubmit = async (values: z.infer<typeof FormSchema>) => { |
| 341 | if (!currentOrg) return console.error('Unable to retrieve current organization') |
| 342 | |
| 343 | const { |
| 344 | cloudProvider, |
| 345 | projectName, |
| 346 | highAvailability, |
| 347 | dbPass, |
| 348 | dbRegion, |
| 349 | postgresVersion, |
| 350 | instanceType, |
| 351 | instanceSize, |
| 352 | dataApi, |
| 353 | dataApiDefaultPrivileges, |
| 354 | enableRlsEventTrigger, |
| 355 | postgresVersionSelection, |
| 356 | useOrioleDb, |
| 357 | githubInstallationId, |
| 358 | githubRepositoryId, |
| 359 | } = values |
| 360 | |
| 361 | if (useOrioleDb && !availableOrioleVersion) { |
| 362 | return toast.error('No available OrioleDB image found, only Postgres is available') |
| 363 | } |
| 364 | |
| 365 | const { postgresEngine, releaseChannel } = |
| 366 | extractPostgresVersionDetails(postgresVersionSelection) |
| 367 | |
| 368 | const { smartGroup = [], specific = [] } = availableRegionsData?.all ?? {} |
| 369 | const selectedRegion = smartRegionEnabled |
| 370 | ? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion)) |
| 371 | : undefined |
| 372 | const parsedGitHubRepositoryId = |
| 373 | githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined |
| 374 | const shouldIncludeGitHubFields = |
| 375 | githubInstallationId !== undefined && Number.isFinite(parsedGitHubRepositoryId) |
| 376 | |
| 377 | const data: ProjectCreateVariables = { |
| 378 | dbPass, |
| 379 | cloudProvider, |
| 380 | organizationSlug: currentOrg.slug, |
| 381 | name: projectName, |
| 382 | highAvailability, |
| 383 | // gets ignored due to org billing subscription anyway |
| 384 | dbPricingTierId: 'tier_free', |
| 385 | // only set the compute size on pro+ plans. Free plans always use micro (nano in the future) size. |
| 386 | dbInstanceSize: isFreePlan ? undefined : (instanceSize as DesiredInstanceSize), |
| 387 | dataApiExposedSchemas: !dataApi ? [] : undefined, |
| 388 | dataApiUseApiSchema: false, |
| 389 | dataApiRevokeDefaultPrivileges: dataApi && !dataApiDefaultPrivileges, |
| 390 | postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine, |
| 391 | releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel, |
| 392 | ...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }), |
| 393 | dbSql: enableRlsEventTrigger ? AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL : undefined, |
| 394 | ...(shouldIncludeGitHubFields |
| 395 | ? { |
| 396 | githubInstallationId, |
| 397 | githubRepositoryId: parsedGitHubRepositoryId, |
| 398 | } |
| 399 | : {}), |
| 400 | } |
| 401 | |
| 402 | if (postgresVersion && !postgresVersion.match(/1[2-9]\..*/)) { |
| 403 | toast.error( |
| 404 | `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` |
| 405 | ) |
| 406 | } |
| 407 | |
| 408 | if (postgresVersion || instanceType) { |
| 409 | data['customBrivenRequest'] = { |
| 410 | ami: { |
| 411 | ...(postgresVersion && { |
| 412 | search_tags: { 'tag:postgresVersion': postgresVersion }, |
| 413 | }), |
| 414 | ...(instanceType && { instance_type: instanceType }), |
| 415 | }, |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | createProject(data) |
| 420 | } |
| 421 | |
| 422 | useEffect(() => { |
| 423 | // Only set once to ensure compute credits dont change while project is being created |
| 424 | if (allOrgProjects && allOrgProjects.length > 0 && !allProjects) { |
| 425 | setAllProjects(allOrgProjects) |
| 426 | } |
| 427 | }, [allOrgProjects, allProjects, setAllProjects]) |
| 428 | |
| 429 | useEffect(() => { |
| 430 | // Handle no org: redirect to new org route |
| 431 | if (isEmptyOrganizations) { |
| 432 | router.push(`/new`) |
| 433 | } |
| 434 | }, [isEmptyOrganizations, router]) |
| 435 | |
| 436 | useEffect(() => { |
| 437 | // [Joshen] Cause slug depends on router which doesnt load immediately on render |
| 438 | // While the form data does load immediately |
| 439 | if (slug && slug !== '_') setValue('organization', slug) |
| 440 | if (projectName) setValue('projectName', projectName || '') |
| 441 | }, [slug, setValue, projectName]) |
| 442 | |
| 443 | const isDbRegionDirty = getFieldState('dbRegion', form.formState).isDirty |
| 444 | useEffect(() => { |
| 445 | if (!isDbRegionDirty && defaultRegion) { |
| 446 | setValue('dbRegion', defaultRegion) |
| 447 | } |
| 448 | }, [defaultRegion, isDbRegionDirty, setValue]) |
| 449 | |
| 450 | useEffect(() => { |
| 451 | if (regionError) { |
| 452 | resetField('dbRegion', { |
| 453 | defaultValue: PROVIDERS[defaultProvider].default_region.displayName, |
| 454 | }) |
| 455 | } |
| 456 | }, [regionError, resetField, defaultProvider]) |
| 457 | |
| 458 | useEffect(() => { |
| 459 | if (!isDbRegionDirty && recommendedSmartRegion) { |
| 460 | setValue('dbRegion', recommendedSmartRegion) |
| 461 | } |
| 462 | }, [recommendedSmartRegion, isDbRegionDirty, setValue]) |
| 463 | |
| 464 | useEffect(() => { |
| 465 | if (highAvailability && cloudProvider !== 'AWS_K8S') { |
| 466 | setValue('cloudProvider', 'AWS_K8S') |
| 467 | } |
| 468 | }, [highAvailability, cloudProvider, setValue]) |
| 469 | |
| 470 | useEffect(() => { |
| 471 | if (watchedInstanceSize !== instanceSize) { |
| 472 | setValue('instanceSize', instanceSize, { |
| 473 | shouldDirty: false, |
| 474 | shouldValidate: false, |
| 475 | shouldTouch: false, |
| 476 | }) |
| 477 | } |
| 478 | }, [instanceSize, watchedInstanceSize, setValue]) |
| 479 | |
| 480 | useEffect(() => { |
| 481 | if (!githubRepositoryName) return |
| 482 | if ((watchedProjectName ?? '').trim().length > 0) return |
| 483 | |
| 484 | const repoName = githubRepositoryName.split('/').at(-1) ?? githubRepositoryName |
| 485 | setValue('projectName', repoName.trim(), { |
| 486 | shouldValidate: true, |
| 487 | }) |
| 488 | }, [githubRepositoryName, watchedProjectName, setValue]) |
| 489 | |
| 490 | return ( |
| 491 | <> |
| 492 | {/* Wizard layouts set the visual header but not the browser tab title. */} |
| 493 | <Head> |
| 494 | <title>{pageTitle}</title> |
| 495 | <meta name="description" content="Briven Studio" /> |
| 496 | </Head> |
| 497 | <Form {...form}> |
| 498 | <form onSubmit={form.handleSubmit(onSubmitWithComputeCostsConfirmation)}> |
| 499 | <Panel |
| 500 | loading={!isOrganizationsSuccess} |
| 501 | title={ |
| 502 | <div key="panel-title"> |
| 503 | <h3>Create a new project</h3> |
| 504 | <p className="text-sm text-foreground-lighter text-balance"> |
| 505 | Your project will have its own dedicated instance and full Postgres database. An |
| 506 | API will be set up so you can easily interact with your new database. |
| 507 | </p> |
| 508 | </div> |
| 509 | } |
| 510 | footer={ |
| 511 | <ProjectCreationFooter |
| 512 | form={form} |
| 513 | canCreateProject={canCreateProject} |
| 514 | instanceSize={instanceSize} |
| 515 | organizationProjects={organizationProjects} |
| 516 | isCreatingNewProject={isCreatingNewProject} |
| 517 | isSuccessNewProject={isSuccessNewProject} |
| 518 | /> |
| 519 | } |
| 520 | > |
| 521 | <> |
| 522 | {projectCreationDisabled ? ( |
| 523 | <DisabledWarningDueToIncident title="Project creation is currently disabled" /> |
| 524 | ) : ( |
| 525 | <div className="divide-y divide-border-border"> |
| 526 | <OrganizationSelector form={form} /> |
| 527 | |
| 528 | {canCreateProject && ( |
| 529 | <> |
| 530 | {canConfigureGitHubOnCreate && ( |
| 531 | <Panel.Content> |
| 532 | <GitHubRepositoryField |
| 533 | form={form} |
| 534 | name="githubRepositoryId" |
| 535 | installationIdField="githubInstallationId" |
| 536 | repositoryNameField="githubRepositoryName" |
| 537 | label="GitHub (optional)" |
| 538 | description={ |
| 539 | <> |
| 540 | Ideal for agent-first workflows: update your schema in code, push it |
| 541 | to GitHub, and Briven deploys the changes automatically.{' '} |
| 542 | <a |
| 543 | href="https://supabase.com/docs/guides/deployment/branching/github-integration" |
| 544 | target="_blank" |
| 545 | rel="noreferrer noopener" |
| 546 | className="text-link" |
| 547 | > |
| 548 | Learn more |
| 549 | </a> |
| 550 | </> |
| 551 | } |
| 552 | disabled={isCreatingNewProject} |
| 553 | repositories={githubRepos} |
| 554 | gitHubAuthorization={gitHubAuthorization} |
| 555 | hasPartialResponseDueToSSO={hasPartialResponseDueToSSO} |
| 556 | isLoading={isLoadingRepositoryOptions} |
| 557 | refetch={refetchRepositoryOptions} |
| 558 | onConnectClick={() => track('project_creation_github_connect_clicked')} |
| 559 | /> |
| 560 | </Panel.Content> |
| 561 | )} |
| 562 | <ProjectNameInput form={form} /> |
| 563 | |
| 564 | {canChooseInstanceSize && <ComputeSizeSelector form={form} />} |
| 565 | |
| 566 | <DatabasePasswordInput form={form} /> |
| 567 | |
| 568 | <RegionSelector |
| 569 | form={form} |
| 570 | instanceSize={instanceSize as DesiredInstanceSize} |
| 571 | /> |
| 572 | |
| 573 | <SecurityOptions form={form} /> |
| 574 | |
| 575 | {showInternalOnlyConfiguration && <InternalOnlyConfiguration form={form} />} |
| 576 | |
| 577 | {showAdvancedConfig && !!availableOrioleVersion && ( |
| 578 | <AdvancedConfiguration form={form} /> |
| 579 | )} |
| 580 | |
| 581 | {shouldShowFreeProjectInfo ? ( |
| 582 | <Admonition |
| 583 | className="rounded-none border-0" |
| 584 | type="note" |
| 585 | title="Need a free project?" |
| 586 | description={ |
| 587 | <p> |
| 588 | You can have up to 2 free projects across all organizations.{' '} |
| 589 | <Link className="underline text-foreground" href="/new"> |
| 590 | Create a free organization |
| 591 | </Link>{' '} |
| 592 | to use them. |
| 593 | </p> |
| 594 | } |
| 595 | /> |
| 596 | ) : null} |
| 597 | </> |
| 598 | )} |
| 599 | |
| 600 | {freePlanWithExceedingLimits ? ( |
| 601 | isAdmin && |
| 602 | slug && ( |
| 603 | <FreeProjectLimitWarning membersExceededLimit={membersExceededLimit || []} /> |
| 604 | ) |
| 605 | ) : hasOutstandingInvoices ? ( |
| 606 | <Panel.Content> |
| 607 | <Admonition |
| 608 | type="default" |
| 609 | title="Your organization has overdue invoices" |
| 610 | description={ |
| 611 | <div className="space-y-3"> |
| 612 | <p className="text-sm leading-normal"> |
| 613 | Please resolve all outstanding invoices first before creating a new |
| 614 | project |
| 615 | </p> |
| 616 | |
| 617 | <div> |
| 618 | <Button asChild type="default"> |
| 619 | <Link href={`/org/${slug}/billing#invoices`}>View invoices</Link> |
| 620 | </Button> |
| 621 | </div> |
| 622 | </div> |
| 623 | } |
| 624 | /> |
| 625 | </Panel.Content> |
| 626 | ) : null} |
| 627 | </div> |
| 628 | )} |
| 629 | </> |
| 630 | </Panel> |
| 631 | |
| 632 | <ConfirmationModal |
| 633 | size="large" |
| 634 | loading={false} |
| 635 | visible={isComputeCostsConfirmationModalVisible} |
| 636 | title="Confirm compute costs" |
| 637 | confirmLabel="I understand" |
| 638 | onCancel={() => setIsComputeCostsConfirmationModalVisible(false)} |
| 639 | onConfirm={async () => { |
| 640 | const values = form.getValues() |
| 641 | await onSubmit(values) |
| 642 | setIsComputeCostsConfirmationModalVisible(false) |
| 643 | }} |
| 644 | variant={'warning'} |
| 645 | > |
| 646 | <div className="text-sm text-foreground-light space-y-1"> |
| 647 | <p> |
| 648 | Launching a project on compute size "{instanceLabel(instanceSize)}" increases your |
| 649 | monthly costs by ${additionalMonthlySpend}, independent of how actively you use it. |
| 650 | By clicking "I understand", you agree to the additional costs.{' '} |
| 651 | <Link |
| 652 | href={`${DOCS_URL}/guides/platform/manage-your-usage/compute`} |
| 653 | target="_blank" |
| 654 | className="underline" |
| 655 | > |
| 656 | Compute Costs |
| 657 | </Link>{' '} |
| 658 | are non-refundable. |
| 659 | </p> |
| 660 | </div> |
| 661 | </ConfirmationModal> |
| 662 | </form> |
| 663 | </Form> |
| 664 | </> |
| 665 | ) |
| 666 | } |
| 667 | |
| 668 | const PageLayout = withAuth(({ children }: PropsWithChildren) => { |
| 669 | return <WizardLayoutWithoutAuth>{children}</WizardLayoutWithoutAuth> |
| 670 | }) |
| 671 | |
| 672 | Wizard.getLayout = (page) => ( |
| 673 | <DefaultLayout hideMobileMenu headerTitle="New project"> |
| 674 | <PageLayout>{page}</PageLayout> |
| 675 | </DefaultLayout> |
| 676 | ) |
| 677 | |
| 678 | export default Wizard |