Skip to content

Commit f4a6d4f

Browse files
committed
feat(onboarding): add Vercel onboarding route and OAuth handling
Add a new Remix route apps/webapp/app/routes/onboarding.vercel.tsx that implements server loader and token exchange to support Vercel integration onboarding. Introduce schemas (zod) for loader query parameters and form actions, and add helper exchangeCodeForToken to call Vercel's OAuth token endpoint with robust error logging. Fetch the current user's organizations and projects from Prisma in the loader, validate incoming query params, and handle missing/misconfigured Vercel client credentials with clear log messages. Add typed TokenResponse shape, use env variables for client credentials and redirect URI, and use redirectWithErrorMessage for user-facing failures. Motivation: enable users to connect a Vercel integration during onboarding by validating parameters, exchanging OAuth codes, and presenting organization/project selection backed by the app database.
1 parent fb591e1 commit f4a6d4f

File tree

4 files changed

+700
-62
lines changed

4 files changed

+700
-62
lines changed

apps/webapp/app/routes/_app.orgs.$organizationSlug_.projects.new/route.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
organizationPath,
3232
selectPlanPath,
3333
v3ProjectPath,
34+
v3ProjectSettingsPath,
3435
} from "~/utils/pathBuilder";
3536

3637
export async function loader({ params, request }: LoaderFunctionArgs) {
@@ -122,6 +123,8 @@ export const action: ActionFunction = async ({ request, params }) => {
122123
const params = new URLSearchParams({
123124
code,
124125
configurationId,
126+
organizationId: project.organization.id,
127+
projectId: project.id,
125128
});
126129
if (next) {
127130
params.set("next", next);

apps/webapp/app/routes/callback.vercel.ts

Lines changed: 117 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const VercelCallbackSchema = z
6262
configurationId: z.string().optional(),
6363
teamId: z.string().nullable().optional(),
6464
next: z.string().optional(),
65+
organizationId: z.string().optional(),
66+
projectId: z.string().optional(),
6567
})
6668
.passthrough();
6769

@@ -296,67 +298,72 @@ async function handleMarketplaceInvokedFlow(params: {
296298
code,
297299
configurationId,
298300
integration: "vercel",
301+
fromMarketplace: "true",
299302
});
300303
if (nextUrl) {
301304
onboardingParams.set("next", nextUrl);
302305
}
303306
return redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`);
304307
}
305308

306-
// Has organizations but no projects - redirect to project creation
309+
// Check if user has organizations with projects
307310
const hasProjects = userOrganizations.some((org) => org.projects.length > 0);
308311
if (!hasProjects) {
309312
const firstOrg = userOrganizations[0];
310313
const projectParams = new URLSearchParams({
311314
code,
312315
configurationId,
313316
integration: "vercel",
317+
fromMarketplace: "true",
314318
});
315319
if (nextUrl) {
316320
projectParams.set("next", nextUrl);
317321
}
318322
return redirect(`${newProjectPath({ slug: firstOrg.slug })}?${projectParams.toString()}`);
319323
}
320324

321-
// User has orgs and projects - complete the installation
322-
const tokenResponse = await exchangeCodeForToken(code);
323-
if (!tokenResponse) {
324-
return redirectWithErrorMessage(
325-
"/",
326-
request,
327-
"Failed to connect to Vercel. Please try again."
328-
);
325+
// Multiple organizations - redirect to onboarding
326+
if (userOrganizations.length > 1) {
327+
const selectionParams = new URLSearchParams({
328+
code,
329+
configurationId,
330+
});
331+
if (nextUrl) {
332+
selectionParams.set("next", nextUrl);
333+
}
334+
return redirect(`/onboarding/vercel?${selectionParams.toString()}`);
329335
}
330336

331-
const config = await VercelIntegrationRepository.getVercelIntegrationConfiguration(
332-
tokenResponse.accessToken,
333-
configurationId,
334-
tokenResponse.teamId ?? null
335-
);
337+
// Single organization - check project count
338+
const singleOrg = userOrganizations[0];
339+
340+
if (singleOrg.projects.length > 1) {
341+
const projectParams = new URLSearchParams({
342+
organizationId: singleOrg.id,
343+
code,
344+
configurationId,
345+
});
346+
if (nextUrl) {
347+
projectParams.set("next", nextUrl);
348+
}
349+
return redirect(`/onboarding/vercel?${projectParams.toString()}`);
350+
}
336351

337-
if (!config) {
352+
// Single org with single project - complete installation directly
353+
const tokenResponse = await exchangeCodeForToken(code);
354+
if (!tokenResponse) {
338355
return redirectWithErrorMessage(
339356
"/",
340357
request,
341-
"Failed to fetch Vercel integration configuration. Please try again."
358+
"Failed to connect to Vercel. Your session may have expired. Please try again from Vercel."
342359
);
343360
}
344361

345-
const userOrg = userOrganizations[0];
346-
const userProject = userOrg.projects[0];
347-
348-
if (!userProject) {
349-
const projectParams = new URLSearchParams({
350-
code,
351-
configurationId,
352-
integration: "vercel",
353-
});
354-
return redirect(`${newProjectPath({ slug: userOrg.slug })}?${projectParams.toString()}`);
355-
}
362+
const singleProject = singleOrg.projects[0];
356363

357364
const environment = await prisma.runtimeEnvironment.findFirst({
358365
where: {
359-
projectId: userProject.id,
366+
projectId: singleProject.id,
360367
slug: "prod",
361368
archivedAt: null,
362369
},
@@ -371,11 +378,11 @@ async function handleMarketplaceInvokedFlow(params: {
371378
}
372379

373380
const stateData: StateData = {
374-
organizationId: userOrg.id,
375-
projectId: userProject.id,
381+
organizationId: singleOrg.id,
382+
projectId: singleProject.id,
376383
environmentSlug: environment.slug,
377-
organizationSlug: userOrg.slug,
378-
projectSlug: userProject.slug,
384+
organizationSlug: singleOrg.slug,
385+
projectSlug: singleProject.slug,
379386
};
380387

381388
const project = await fetchProjectWithAccess(stateData.projectId, stateData.organizationId, userId);
@@ -420,7 +427,21 @@ async function handleSelfInvokedFlow(params: {
420427
logger.error("Invalid Vercel OAuth state JWT", {
421428
error: validationResult.error,
422429
});
423-
throw new Response("Invalid state parameter", { status: 400 });
430+
431+
// Check if JWT has expired
432+
if (validationResult.error?.includes("expired") || validationResult.error?.includes("Token has expired")) {
433+
return redirectWithErrorMessage(
434+
"/",
435+
request,
436+
"Your installation session has expired. Please start the installation again."
437+
);
438+
}
439+
440+
return redirectWithErrorMessage(
441+
"/",
442+
request,
443+
"Invalid installation session. Please try again."
444+
);
424445
}
425446

426447
const stateData = validationResult.state;
@@ -500,6 +521,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
500521
error_description,
501522
configurationId,
502523
next: nextUrl,
524+
organizationId,
525+
projectId,
503526
} = parsedParams.data;
504527

505528
// Handle errors from Vercel
@@ -514,6 +537,67 @@ export async function loader({ request }: LoaderFunctionArgs) {
514537
throw new Response("Missing authorization code", { status: 400 });
515538
}
516539

540+
// Handle return from project creation with org and project IDs
541+
if (organizationId && projectId && configurationId && !state) {
542+
const project = await fetchProjectWithAccess(projectId, organizationId, authenticatedUserId);
543+
544+
if (!project) {
545+
logger.error("Project not found or user does not have access", {
546+
projectId,
547+
organizationId,
548+
userId: authenticatedUserId,
549+
});
550+
return redirectWithErrorMessage(
551+
"/",
552+
request,
553+
"Project not found. Please try again."
554+
);
555+
}
556+
557+
const tokenResponse = await exchangeCodeForToken(code);
558+
if (!tokenResponse) {
559+
return redirectWithErrorMessage(
560+
"/",
561+
request,
562+
"Failed to connect to Vercel. Your session may have expired. Please try again from Vercel."
563+
);
564+
}
565+
566+
const environment = await prisma.runtimeEnvironment.findFirst({
567+
where: {
568+
projectId: project.id,
569+
slug: "prod",
570+
archivedAt: null,
571+
},
572+
});
573+
574+
if (!environment) {
575+
return redirectWithErrorMessage(
576+
"/",
577+
request,
578+
"Failed to find project environment. Please try again."
579+
);
580+
}
581+
582+
const stateData: StateData = {
583+
organizationId: project.organizationId,
584+
projectId: project.id,
585+
environmentSlug: environment.slug,
586+
organizationSlug: project.organization.slug,
587+
projectSlug: project.slug,
588+
};
589+
590+
return completeIntegrationSetup({
591+
tokenResponse,
592+
project,
593+
stateData,
594+
configurationId,
595+
nextUrl,
596+
request,
597+
logContext: "after project creation",
598+
});
599+
}
600+
517601
// Route to appropriate handler based on presence of state parameter
518602
if (state) {
519603
// Self-invoked flow: user clicked connect from our app

0 commit comments

Comments
 (0)