Skip to content

Commit 12cbac8

Browse files
committed
feat(vercel): Vercel side initialized installation
Preserve full search params when redirecting unauthenticated users to login so Vercel callback params (code, configurationId, etc.) are not lost. Add handling for Vercel callbacks that arrive without a state parameter (common for Vercel-side installations). When state is absent but configurationId is present: - Query the user's organizations and projects. - If the user has no organizations, redirect to basic details onboarding with the Vercel params preserved. - If the user has organizations but no projects, redirect to the new project creation page for the first org with the Vercel params. - If the user has orgs and projects, exchange the code for a token, fetch the Vercel integration configuration, find the default project and environment, and continue the installation flow (including generating a state JWT). Add imports for new path helpers and OAuth state generator, and wire in logic to build redirect URLs with next param when present. Improve error handling and user-facing error redirects for token and configuration fetch failures.
1 parent d55c23c commit 12cbac8

File tree

11 files changed

+423
-21
lines changed

11 files changed

+423
-21
lines changed

apps/webapp/app/models/vercelIntegration.server.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,70 @@ export class VercelIntegrationRepository {
116116
return secret.teamId ?? null;
117117
}
118118

119+
/**
120+
* Get Vercel integration configuration by configurationId
121+
* This is used when Vercel redirects to our callback without a state parameter
122+
*/
123+
static async getVercelIntegrationConfiguration(
124+
accessToken: string,
125+
configurationId: string,
126+
teamId?: string | null
127+
): Promise<{
128+
id: string;
129+
teamId: string | null;
130+
projects: string[];
131+
} | null> {
132+
try {
133+
const client = new Vercel({
134+
bearerToken: accessToken,
135+
});
136+
137+
// Use the Vercel SDK to get the integration configuration
138+
// The SDK might have a method for this, or we need to make a direct API call
139+
const response = await fetch(
140+
`https://api.vercel.com/v1/integrations/configuration/${configurationId}${teamId ? `?teamId=${teamId}` : ""}`,
141+
{
142+
method: "GET",
143+
headers: {
144+
Authorization: `Bearer ${accessToken}`,
145+
"Content-Type": "application/json",
146+
},
147+
}
148+
);
149+
150+
if (!response.ok) {
151+
const errorText = await response.text();
152+
logger.error("Failed to fetch Vercel integration configuration", {
153+
status: response.status,
154+
error: errorText,
155+
configurationId,
156+
teamId,
157+
});
158+
return null;
159+
}
160+
161+
const data = (await response.json()) as {
162+
id: string;
163+
teamId?: string | null;
164+
projects?: string[];
165+
[key: string]: any;
166+
};
167+
168+
return {
169+
id: data.id,
170+
teamId: data.teamId ?? null,
171+
projects: data.projects || [],
172+
};
173+
} catch (error) {
174+
logger.error("Error fetching Vercel integration configuration", {
175+
configurationId,
176+
teamId,
177+
error,
178+
});
179+
return null;
180+
}
181+
}
182+
119183
/**
120184
* Fetch custom environments for a Vercel project.
121185
* Excludes standard environments (production, preview, development).

apps/webapp/app/presenters/v3/BranchesPresenter.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type GitMetaLinks = {
4242
/** The git provider, e.g., `github` */
4343
provider?: string;
4444

45-
source?: "trigger_github_app" | "github_actions" | "local";
45+
source?: "trigger_github_app" | "github_actions" | "local" | "trigger_vercel_app";
4646
ghUsername?: string;
4747
ghUserAvatarUrl?: string;
4848
};

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ export default function Page() {
310310

311311
// Vercel onboarding modal state
312312
const hasQueryParam = searchParams.get("vercelOnboarding") === "true";
313+
const nextUrl = searchParams.get("next");
313314
const [isModalOpen, setIsModalOpen] = useState(false);
314315
const vercelFetcher = useTypedFetcher<any>();
315316

@@ -621,6 +622,7 @@ export default function Page() {
621622
environmentSlug={environment.slug}
622623
hasStagingEnvironment={vercelFetcher.data?.hasStagingEnvironment ?? false}
623624
hasOrgIntegration={vercelFetcher.data?.hasOrgIntegration ?? false}
625+
nextUrl={nextUrl ?? undefined}
624626
onDataReload={() => {
625627
vercelFetcher.load(
626628
`${vercelResourcePath(organization.slug, project.slug, environment.slug)}?vercelOnboarding=true`

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.vercel.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ import { TrashIcon } from "@heroicons/react/20/solid";
3232
import { vercelResourcePath } from "~/utils/pathBuilder";
3333
import { LinkButton } from "~/components/primitives/Buttons";
3434

35+
function formatDate(date: Date): string {
36+
return new Intl.DateTimeFormat("en-US", {
37+
month: "short",
38+
day: "numeric",
39+
year: "numeric",
40+
hour: "numeric",
41+
minute: "2-digit",
42+
second: "2-digit",
43+
hour12: true,
44+
}).format(date);
45+
}
46+
3547
const SearchParamsSchema = z.object({
3648
configurationId: z.string().optional(),
3749
});
@@ -261,7 +273,7 @@ export default function VercelIntegrationPage() {
261273
)}
262274
<div>
263275
<span className="font-medium">Installed:</span>{" "}
264-
{new Date(vercelIntegration.createdAt).toLocaleDateString()}
276+
{formatDate(new Date(vercelIntegration.createdAt))}
265277
</div>
266278
</div>
267279
</div>
@@ -347,7 +359,7 @@ export default function VercelIntegrationPage() {
347359
{projectIntegration.externalEntityId}
348360
</TableCell>
349361
<TableCell>
350-
{new Date(projectIntegration.createdAt).toLocaleDateString()}
362+
{formatDate(new Date(projectIntegration.createdAt))}
351363
</TableCell>
352364
<TableCell>
353365
<LinkButton

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ export const action: ActionFunction = async ({ request, params }) => {
103103
return json(submission);
104104
}
105105

106+
// Check for Vercel integration params in URL
107+
const url = new URL(request.url);
108+
const code = url.searchParams.get("code");
109+
const configurationId = url.searchParams.get("configurationId");
110+
const next = url.searchParams.get("next");
111+
106112
try {
107113
const project = await createProject({
108114
organizationSlug: organizationSlug,
@@ -111,6 +117,19 @@ export const action: ActionFunction = async ({ request, params }) => {
111117
version: submission.value.projectVersion,
112118
});
113119

120+
// If this is a Vercel integration flow, redirect back to callback
121+
if (code && configurationId) {
122+
const params = new URLSearchParams({
123+
code,
124+
configurationId,
125+
});
126+
if (next) {
127+
params.set("next", next);
128+
}
129+
const callbackUrl = `/callback/vercel?${params.toString()}`;
130+
return redirect(callbackUrl);
131+
}
132+
114133
return redirectWithSuccessMessage(
115134
v3ProjectPath(project.organization, project),
116135
request,

apps/webapp/app/routes/_app.orgs.new/route.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ export const action: ActionFunction = async ({ request }) => {
6969
});
7070
}
7171

72+
// Preserve Vercel integration params if present
73+
const url = new URL(request.url);
74+
const code = url.searchParams.get("code");
75+
const configurationId = url.searchParams.get("configurationId");
76+
const integration = url.searchParams.get("integration");
77+
const next = url.searchParams.get("next");
78+
79+
if (code && configurationId && integration === "vercel") {
80+
// Redirect to projects/new with params preserved
81+
const params = new URLSearchParams({
82+
code,
83+
configurationId,
84+
integration,
85+
});
86+
if (next) {
87+
params.set("next", next);
88+
}
89+
const redirectUrl = `${organizationPath(organization)}/projects/new?${params.toString()}`;
90+
return redirect(redirectUrl);
91+
}
92+
7293
return redirect(organizationPath(organization));
7394
} catch (error: any) {
7495
return json({ errors: { body: error.message } }, { status: 400 });

0 commit comments

Comments
 (0)