import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json, redirect } from "@remix-run/server-runtime"; import { fromPromise } from "neverthrow"; import { useEffect, useState } from "react"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BuildingOfficeIcon, FolderIcon } from "@heroicons/react/20/solid"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { BackgroundWrapper } from "~/components/BackgroundWrapper"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormTitle } from "~/components/primitives/FormTitle"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Select, SelectItem } from "~/components/primitives/Select"; import { ButtonSpinner } from "~/components/primitives/Spinner"; import { prisma } from "~/db.server"; import { authenticator } from "~/services/auth.server"; import { logger } from "~/services/logger.server"; import { requireUserId } from "~/services/session.server"; import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server"; import { confirmBasicDetailsPath, newProjectPath } from "~/utils/pathBuilder"; import { redirectWithErrorMessage } from "~/models/message.server"; import { generateVercelOAuthState } from "~/v3/vercel/vercelOAuthState.server"; const LoaderParamsSchema = z.object({ organizationId: z.string().optional().nullable(), code: z.string().optional().nullable(), configurationId: z.string().optional().nullable(), next: z.string().optional().nullable(), error: z.string().optional().nullable(), }); const SelectOrgActionSchema = z.object({ action: z.literal("select-org"), organizationId: z.string(), code: z.string(), configurationId: z.string().optional().nullable(), next: z.string().optional(), }); const SelectProjectActionSchema = z.object({ action: z.literal("select-project"), projectId: z.string(), organizationId: z.string(), code: z.string(), configurationId: z.string().optional().nullable(), next: z.string().optional().nullable(), }); const ActionSchema = z.discriminatedUnion("action", [ SelectOrgActionSchema, SelectProjectActionSchema, ]); export async function loader({ request }: LoaderFunctionArgs) { const userId = await requireUserId(request); const url = new URL(request.url); const params = LoaderParamsSchema.safeParse({ organizationId: url.searchParams.get("organizationId"), code: url.searchParams.get("code"), configurationId: url.searchParams.get("configurationId"), next: url.searchParams.get("next"), error: url.searchParams.get("error"), }); if (!params.success) { logger.error("Invalid params for Vercel onboarding", { error: params.error }); throw await redirectWithErrorMessage( "/", request, "Invalid installation parameters. Please try again from Vercel." ); } const { error } = params.data; if (error === "expired") { return typedjson({ step: "error" as const, error: "Your installation session has expired. Please start the installation again.", code: params.data.code ?? null, configurationId: params.data.configurationId ?? null, next: params.data.next ?? null, }); } if (!params.data.code) { logger.error("Missing code parameter for Vercel onboarding"); throw await redirectWithErrorMessage( "/", request, "Invalid installation parameters. Please try again from Vercel." ); } const code = params.data.code; const organizations = await prisma.organization.findMany({ where: { members: { some: { userId }, }, deletedAt: null, }, select: { id: true, title: true, slug: true, projects: { where: { deletedAt: null, }, select: { id: true, name: true, slug: true, }, orderBy: { createdAt: "asc", }, }, }, orderBy: { createdAt: "asc", }, }); // New user: no organizations if (organizations.length === 0) { const onboardingParams = new URLSearchParams(); onboardingParams.set("code", code); if (params.data.configurationId) { onboardingParams.set("configurationId", params.data.configurationId); } onboardingParams.set("integration", "vercel"); if (params.data.next) { onboardingParams.set("next", params.data.next); } throw redirect(`${confirmBasicDetailsPath()}?${onboardingParams.toString()}`); } // If organizationId is provided, show project selection if (params.data.organizationId) { const organization = organizations.find((org) => org.id === params.data.organizationId); if (!organization) { logger.error("Organization not found or access denied", { organizationId: params.data.organizationId, userId, }); throw await redirectWithErrorMessage( "/", request, "Organization not found. Please try again." ); } return typedjson({ step: "project" as const, organization, organizations, code: code, configurationId: params.data.configurationId ?? null, next: params.data.next ?? null, }); } return typedjson({ step: "org" as const, organizations, code: code, configurationId: params.data.configurationId ?? null, next: params.data.next ?? null, }); } export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request); const formData = await request.formData(); const submission = ActionSchema.safeParse({ action: formData.get("action"), organizationId: formData.get("organizationId"), projectId: formData.get("projectId"), code: formData.get("code"), configurationId: formData.get("configurationId"), next: formData.get("next"), }); if (!submission.success) { return json({ error: "Invalid submission" }, { status: 400 }); } // SSO auto-discovery: if the signed-in user's domain requires SSO, the // current session was established via a non-SSO method — block the // onboarding action and route them through the SSO flow instead. const sessionUser = await prisma.user.findFirst({ where: { id: userId }, select: { email: true }, }); if (sessionUser?.email) { // Preserve the in-progress Vercel install across the SSO handoff: // rebuild the onboarding URL (same shape the org-step redirect below // uses) and pass it as `redirectTo` so the single-use `code`, // `configurationId`, and `next` aren't lost when the user is bounced // to their identity provider. const resumeParams = new URLSearchParams(); resumeParams.set("code", submission.data.code); if (submission.data.configurationId) { resumeParams.set("configurationId", submission.data.configurationId); } if (submission.data.next) { resumeParams.set("next", submission.data.next); } const resumeUrl = `/vercel/onboarding?${resumeParams.toString()}`; const ssoRedirect = await ssoRedirectForEmail(sessionUser.email, "oauth_blocked", resumeUrl); if (ssoRedirect) { // The user is already authenticated via a non-SSO method, so a plain // redirect to `/login/sso` would be bounced straight home by that // route's already-authenticated guard — silently dropping the install. // Destroy the current session first (mirroring the OAuth callbacks, // which never commit a session when SSO is required) so `/login/sso` // accepts them. `authenticator.logout` redirects to `ssoRedirect` // verbatim — unlike the `/logout` route it doesn't run the redirect // sanitizer, which would otherwise reject the non-navigable // `/login/sso` target. The resume URL rides along as `redirectTo` and // survives the SSO round-trip. return authenticator.logout(request, { redirectTo: ssoRedirect }); } } const { code, configurationId, next } = submission.data; // Handle org selection if (submission.data.action === "select-org") { const { organizationId } = submission.data; const projectParams = new URLSearchParams(); projectParams.set("organizationId", organizationId); projectParams.set("code", code); if (configurationId) { projectParams.set("configurationId", configurationId); } if (next) { projectParams.set("next", next); } return redirect(`/vercel/onboarding?${projectParams.toString()}`); } // Handle project selection const { projectId, organizationId } = submission.data; const project = await prisma.project.findFirst({ where: { id: projectId, organizationId, deletedAt: null, organization: { members: { some: { userId } }, }, }, include: { organization: true, }, }); if (!project) { logger.error("Project not found or access denied", { projectId, userId }); return json({ error: "Project not found" }, { status: 404 }); } const environment = await prisma.runtimeEnvironment.findFirst({ where: { projectId: project.id, slug: "prod", archivedAt: null, }, }); if (!environment) { logger.error("Environment not found", { projectId: project.id }); return json({ error: "Environment not found" }, { status: 404 }); } const stateResult = await fromPromise( generateVercelOAuthState({ organizationId: project.organizationId, projectId: project.id, environmentSlug: environment.slug, organizationSlug: project.organization.slug, projectSlug: project.slug, }), (error) => error ); if (stateResult.isErr()) { logger.error("Failed to generate Vercel OAuth state", { error: stateResult.error }); return json({ error: "Failed to generate installation state" }, { status: 500 }); } const params = new URLSearchParams(); params.set("state", stateResult.value); params.set("code", code); if (configurationId) { params.set("configurationId", configurationId); } params.set("origin", "marketplace"); if (next) { params.set("next", next); } return redirect(`/vercel/connect?${params.toString()}`, 303); } export default function VercelOnboardingPage() { const data = useTypedLoaderData(); const navigation = useNavigation(); const isSubmitting = navigation.state === "submitting"; const [isInstalling, setIsInstalling] = useState(false); // Reset isInstalling when navigation returns to idle (e.g. on error) useEffect(() => { if (navigation.state === "idle" && isInstalling) { setIsInstalling(false); } }, [navigation.state, isInstalling]); if (data.step === "error") { return ( ); } if (data.step === "org") { const newOrgUrl = (() => { const params = new URLSearchParams(); params.set("code", data.code); if (data.configurationId) { params.set("configurationId", data.configurationId); } params.set("integration", "vercel"); if (data.next) { params.set("next", data.next); } return `/orgs/new?${params.toString()}`; })(); return ( } title="Select Organization" description="Choose which organization to install the Vercel integration into." />
{data.configurationId && ( )} {data.next && }
+ New Organization } />
); } const newProjectUrl = (() => { const params = new URLSearchParams(); params.set("code", data.code); if (data.configurationId) { params.set("configurationId", data.configurationId); } params.set("integration", "vercel"); params.set("organizationId", data.organization.id); if (data.next) { params.set("next", data.next); } return `${newProjectPath({ slug: data.organization.slug })}?${params.toString()}`; })(); const isLoading = isSubmitting || isInstalling; return ( } title="Select Project" description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`} />
setIsInstalling(true)}> {data.configurationId && ( )} {data.next && }
+ New Project } />
); }