new-project.tsx412 lines · main
1// @ts-nocheck
2import { useParams } from 'common'
3import { ChangeEvent, useEffect, useRef, useState } from 'react'
4import { AWS_REGIONS } from 'shared-data'
5import { toast } from 'sonner'
6import {
7 Button,
8 Checkbox,
9 Input,
10 Select,
11 SelectContent,
12 SelectItem,
13 SelectTrigger,
14 SelectValue,
15} from 'ui'
16import { Admonition } from 'ui-patterns/admonition'
17import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input'
18import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
19
20import { isVercelUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
21import { Markdown } from '@/components/interfaces/Markdown'
22import VercelIntegrationWindowLayout from '@/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout'
23import { ScaffoldColumn, ScaffoldContainer } from '@/components/layouts/Scaffold'
24import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar'
25import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
26import { useIntegrationsQuery } from '@/data/integrations/integrations-query'
27import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integrations/integrations-vercel-connections-create-mutation'
28import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query'
29import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
30import { useProjectCreateMutation } from '@/data/projects/project-create-mutation'
31import {
32 useDataApiRevokeOnCreateDefaultEnabled,
33 useTrackDefaultPrivilegesExposure,
34} from '@/hooks/misc/useDataApiRevokeOnCreateDefault'
35import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
36import { usePHFlag } from '@/hooks/ui/useFlag'
37import { BASE_PATH, PROVIDERS } from '@/lib/constants'
38import { getInitialMigrationSQLFromGitHubRepo } from '@/lib/integration-utils'
39import { passwordStrength, PasswordStrengthScore } from '@/lib/password-strength'
40import { generateStrongPassword } from '@/lib/project'
41import { useTrack } from '@/lib/telemetry/track'
42import { useIntegrationInstallationSnapshot } from '@/state/integration-installation'
43import type { NextPageWithLayout } from '@/types'
44
45const VercelIntegration: NextPageWithLayout = () => {
46 return (
47 <>
48 <ScaffoldContainer className="flex flex-col gap-6 grow py-8">
49 <ScaffoldColumn className="mx-auto w-full max-w-md">
50 <header>
51 <h2>New project</h2>
52 <Markdown
53 className="text-foreground-light"
54 content={`Choose the Briven organization you wish to install in`}
55 />
56 </header>
57 <CreateProject />
58 <Admonition
59 type="default"
60 layout="horizontal"
61 title="You can uninstall this Integration at any time."
62 description="You can remove this integration at any time via Vercel or the Briven dashboard"
63 />
64 </ScaffoldColumn>
65 </ScaffoldContainer>
66 </>
67 )
68}
69
70VercelIntegration.getLayout = (page) => (
71 <VercelIntegrationWindowLayout>{page}</VercelIntegrationWindowLayout>
72)
73
74const CreateProject = () => {
75 const { data: selectedOrganization } = useSelectedOrganizationQuery()
76 const [projectName, setProjectName] = useState('')
77 const [dbPass, setDbPass] = useState('')
78 const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('')
79 const [passwordStrengthScore, setPasswordStrengthScore] = useState(-1)
80 const [shouldRunMigrations, setShouldRunMigrations] = useState(true)
81 const [dbRegion, setDbRegion] = useState<string>(PROVIDERS.AWS.default_region.displayName)
82
83 const track = useTrack()
84 const snapshot = useIntegrationInstallationSnapshot()
85 const isDataApiRevokeOnCreateDefault = useDataApiRevokeOnCreateDefaultEnabled()
86 const dataApiRevokeOnCreateDefaultFlag = usePHFlag<boolean>('dataApiRevokeOnCreateDefault')
87 const [dataApiDefaultPrivileges, setDataApiDefaultPrivileges] = useState(
88 !isDataApiRevokeOnCreateDefault
89 )
90 const hasUserModifiedDataApiDefaultPrivileges = useRef(false)
91
92 useEffect(() => {
93 if (dataApiRevokeOnCreateDefaultFlag === undefined) return
94 if (hasUserModifiedDataApiDefaultPrivileges.current) return
95 setDataApiDefaultPrivileges(!dataApiRevokeOnCreateDefaultFlag)
96 }, [dataApiRevokeOnCreateDefaultFlag])
97
98 const { slug, next, currentProjectId: foreignProjectId, externalId } = useParams()
99
100 useTrackDefaultPrivilegesExposure({
101 surface: 'vercel',
102 orgSlug: slug,
103 dataApiDefaultPrivileges,
104 hasUserModified: hasUserModifiedDataApiDefaultPrivileges.current,
105 })
106
107 async function checkPasswordStrength(value: string) {
108 const { message, strength } = await passwordStrength(value)
109 setPasswordStrengthScore(strength)
110 setPasswordStrengthMessage(message)
111 }
112
113 const { mutateAsync: createConnections } = useIntegrationVercelConnectionsCreateMutation()
114
115 const { data: organizationData } = useOrganizationsQuery()
116 const organization = organizationData?.find((x) => x.slug === slug)
117
118 /**
119 * array of integrations installed
120 */
121 const { data: integrationData } = useIntegrationsQuery()
122
123 /**
124 * the vercel integration installed for organization chosen
125 */
126 const organizationIntegration = integrationData?.find((x) => x.organization.slug === slug)
127
128 /**
129 * Vercel projects available for this integration
130 */
131 const { data: vercelProjects } = useVercelProjectsQuery(
132 {
133 organization_integration_id: organizationIntegration?.id,
134 },
135 { enabled: organizationIntegration !== undefined }
136 )
137
138 function onProjectNameChange(e: ChangeEvent<HTMLInputElement>) {
139 e.target.value = e.target.value.replace(/\./g, '')
140 setProjectName(e.target.value)
141 }
142
143 function onDbPassChange(e: ChangeEvent<HTMLInputElement>) {
144 const value = e.target.value
145 setDbPass(value)
146 if (value == '') {
147 setPasswordStrengthScore(-1)
148 setPasswordStrengthMessage('')
149 } else checkPasswordStrength(value)
150 }
151
152 function generatePassword() {
153 const password = generateStrongPassword()
154 setDbPass(password)
155 checkPasswordStrength(password)
156 }
157
158 const [newProjectRef, setNewProjectRef] = useState<string | undefined>(undefined)
159
160 const { mutate: createProject } = useProjectCreateMutation({
161 onSuccess: (res) => {
162 setNewProjectRef(res.ref)
163 track(
164 'project_creation_simple_version_submitted',
165 {
166 surface: 'vercel',
167 dataApiEnabled: true,
168 dataApiDefaultPrivilegesGranted: dataApiDefaultPrivileges,
169 ...(dataApiRevokeOnCreateDefaultFlag !== undefined && {
170 dataApiRevokeOnCreateDefaultEnabled: dataApiRevokeOnCreateDefaultFlag,
171 }),
172 },
173 {
174 project: res.ref,
175 organization: res.organization_slug,
176 }
177 )
178 },
179 onError: (error) => {
180 toast.error(error.message)
181 snapshot.setLoading(false)
182 },
183 })
184
185 async function onCreateProject() {
186 if (!organizationIntegration) return console.error('No organization installation details found')
187 if (!organizationIntegration?.id) return console.error('No organization installation ID found')
188 if (!foreignProjectId) return console.error('No foreignProjectId set')
189 if (!organization) return console.error('No organization set')
190
191 snapshot.setLoading(true)
192
193 let dbSql: string | undefined
194 if (shouldRunMigrations) {
195 const id = toast(`Fetching initial migrations from GitHub repo`)
196 const migrationSql = await getInitialMigrationSQLFromGitHubRepo(externalId)
197 if (migrationSql) dbSql = migrationSql
198 toast.success(`Done fetching initial migrations`, { id })
199 }
200
201 createProject({
202 organizationSlug: organization.slug,
203 name: projectName,
204 dbPass,
205 dbRegion,
206 dbSql,
207 dataApiRevokeDefaultPrivileges: !dataApiDefaultPrivileges,
208 })
209 }
210
211 // Wait for the new project to be created before creating the connection
212 const { data, isSuccess } = useProjectSettingsV2Query(
213 { projectRef: newProjectRef },
214 {
215 enabled: newProjectRef !== undefined,
216 // refetch until the project is created
217 refetchInterval: (query) => {
218 const data = query.state.data
219 return ((data?.service_api_keys ?? []).length ?? 0) > 0 ? false : 1000
220 },
221 }
222 )
223 useEffect(() => {
224 if (!isSuccess) return
225 const onSuccessFunc = async () => {
226 const isReady = (data.service_api_keys ?? []).length > 0
227
228 if (!isReady || !organizationIntegration || !foreignProjectId || !newProjectRef) {
229 return
230 }
231
232 const projectDetails = vercelProjects?.find((x: any) => x.id === foreignProjectId)
233
234 try {
235 await createConnections({
236 organizationIntegrationId: organizationIntegration?.id,
237 connection: {
238 foreign_project_id: foreignProjectId,
239 briven_project_ref: newProjectRef,
240 integration_id: '0',
241 metadata: {
242 ...projectDetails,
243 brivenConfig: {
244 projectEnvVars: {
245 write: true,
246 },
247 },
248 },
249 },
250 orgSlug: selectedOrganization?.slug,
251 })
252 } catch (error) {
253 console.error('An error occurred during createConnections:', error)
254 return
255 }
256
257 snapshot.setLoading(false)
258
259 if (next && isVercelUrl(next)) {
260 window.location.href = next
261 }
262 }
263 onSuccessFunc()
264 }, [data, isSuccess])
265
266 return (
267 <div>
268 <p className="mb-2">Briven project details</p>
269 <div className="py-2">
270 <FormItemLayout
271 id="projectName"
272 isReactForm={false}
273 layout="vertical"
274 label="Project name"
275 size="tiny"
276 >
277 <Input
278 autoFocus
279 id="projectName"
280 type="text"
281 placeholder=""
282 value={projectName}
283 onChange={onProjectNameChange}
284 />
285 </FormItemLayout>
286 </div>
287 <div className="py-2">
288 <FormItemLayout
289 id="dbPass"
290 isReactForm={false}
291 layout="vertical"
292 label="Database password"
293 size="tiny"
294 description={
295 <PasswordStrengthBar
296 passwordStrengthScore={passwordStrengthScore as PasswordStrengthScore}
297 password={dbPass}
298 passwordStrengthMessage={passwordStrengthMessage}
299 generateStrongPassword={generatePassword}
300 />
301 }
302 >
303 <PasswordInput
304 id="dbPass"
305 type="password"
306 placeholder="Type in a strong password"
307 value={dbPass}
308 reveal
309 copy={dbPass.length > 0}
310 onChange={onDbPassChange}
311 />
312 </FormItemLayout>
313 </div>
314 <div className="py-2">
315 <div className="mt-1">
316 <FormItemLayout
317 id="region"
318 isReactForm={false}
319 layout="vertical"
320 label="Region"
321 description="Select a region close to your users for the best performance."
322 className="gap-[2px]"
323 size="tiny"
324 >
325 <Select value={dbRegion} onValueChange={(region) => setDbRegion(region)}>
326 <SelectTrigger id="region">
327 <SelectValue />
328 </SelectTrigger>
329 <SelectContent>
330 {Object.keys(AWS_REGIONS).map((option: string, i) => {
331 const label = Object.values(AWS_REGIONS)[i].displayName
332 return (
333 <SelectItem key={option} value={label}>
334 <div className="flex gap-2">
335 <img
336 alt="region icon"
337 className="w-5 rounded-xs"
338 src={`${BASE_PATH}/img/regions/${Object.values(AWS_REGIONS)[i].code}.svg`}
339 />
340 <span>{label}</span>
341 </div>
342 </SelectItem>
343 )
344 })}
345 </SelectContent>
346 </Select>
347 </FormItemLayout>
348 </div>
349 </div>
350 <div className="py-2 pb-4">
351 <div className="items-top flex space-x-2">
352 <Checkbox
353 id="shouldRunMigrations"
354 name="shouldRunMigrations"
355 checked={shouldRunMigrations}
356 onCheckedChange={(checked) => setShouldRunMigrations(!!checked)}
357 />
358 <div className="grid gap-1.5 leading-none">
359 <label
360 htmlFor="enable-realtime"
361 className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
362 >
363 Create sample tables with seed data
364 </label>
365 <p className="text-sm text-foreground-muted">
366 To get you started quickly, we can create new tables for you with seed (sample) data.
367 You can delete these tables later.
368 </p>
369 </div>
370 </div>
371 </div>
372 <div className="py-2 pb-4">
373 <div className="items-top flex space-x-2">
374 <Checkbox
375 id="dataApiDefaultPrivileges"
376 name="dataApiDefaultPrivileges"
377 checked={dataApiDefaultPrivileges}
378 onCheckedChange={(checked) => {
379 hasUserModifiedDataApiDefaultPrivileges.current = true
380 setDataApiDefaultPrivileges(!!checked)
381 }}
382 />
383 <div className="grid gap-1.5 leading-none">
384 <label
385 htmlFor="dataApiDefaultPrivileges"
386 className="text-sm text-foreground-light flex items-center space-x-2 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
387 >
388 Automatically expose new tables
389 </label>
390 <p className="text-sm text-foreground-muted">
391 Grants privileges to Data API roles by default, exposing new tables. We recommend
392 disabling this to control access manually.
393 </p>
394 </div>
395 </div>
396 </div>
397 <div className="flex flex-row w-full justify-end">
398 <Button
399 size="medium"
400 className="self-end"
401 disabled={snapshot.loading}
402 loading={snapshot.loading}
403 onClick={onCreateProject}
404 >
405 Create Project
406 </Button>
407 </div>
408 </div>
409 )
410}
411
412export default VercelIntegration