new-project.tsx412 lines · main
| 1 | // @ts-nocheck |
| 2 | import { useParams } from 'common' |
| 3 | import { ChangeEvent, useEffect, useRef, useState } from 'react' |
| 4 | import { AWS_REGIONS } from 'shared-data' |
| 5 | import { toast } from 'sonner' |
| 6 | import { |
| 7 | Button, |
| 8 | Checkbox, |
| 9 | Input, |
| 10 | Select, |
| 11 | SelectContent, |
| 12 | SelectItem, |
| 13 | SelectTrigger, |
| 14 | SelectValue, |
| 15 | } from 'ui' |
| 16 | import { Admonition } from 'ui-patterns/admonition' |
| 17 | import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' |
| 18 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 19 | |
| 20 | import { isVercelUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils' |
| 21 | import { Markdown } from '@/components/interfaces/Markdown' |
| 22 | import VercelIntegrationWindowLayout from '@/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' |
| 23 | import { ScaffoldColumn, ScaffoldContainer } from '@/components/layouts/Scaffold' |
| 24 | import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar' |
| 25 | import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' |
| 26 | import { useIntegrationsQuery } from '@/data/integrations/integrations-query' |
| 27 | import { useIntegrationVercelConnectionsCreateMutation } from '@/data/integrations/integrations-vercel-connections-create-mutation' |
| 28 | import { useVercelProjectsQuery } from '@/data/integrations/integrations-vercel-projects-query' |
| 29 | import { useOrganizationsQuery } from '@/data/organizations/organizations-query' |
| 30 | import { useProjectCreateMutation } from '@/data/projects/project-create-mutation' |
| 31 | import { |
| 32 | useDataApiRevokeOnCreateDefaultEnabled, |
| 33 | useTrackDefaultPrivilegesExposure, |
| 34 | } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' |
| 35 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 36 | import { usePHFlag } from '@/hooks/ui/useFlag' |
| 37 | import { BASE_PATH, PROVIDERS } from '@/lib/constants' |
| 38 | import { getInitialMigrationSQLFromGitHubRepo } from '@/lib/integration-utils' |
| 39 | import { passwordStrength, PasswordStrengthScore } from '@/lib/password-strength' |
| 40 | import { generateStrongPassword } from '@/lib/project' |
| 41 | import { useTrack } from '@/lib/telemetry/track' |
| 42 | import { useIntegrationInstallationSnapshot } from '@/state/integration-installation' |
| 43 | import type { NextPageWithLayout } from '@/types' |
| 44 | |
| 45 | const 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 | |
| 70 | VercelIntegration.getLayout = (page) => ( |
| 71 | <VercelIntegrationWindowLayout>{page}</VercelIntegrationWindowLayout> |
| 72 | ) |
| 73 | |
| 74 | const 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 | |
| 412 | export default VercelIntegration |