513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
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<typeof loader>();
|
|
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 (
|
|
<AppContainer className="bg-background-deep">
|
|
<BackgroundWrapper>
|
|
<MainCenteredContainer
|
|
variant="onboarding"
|
|
className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
|
|
>
|
|
<FormTitle title="Installation Expired" description={data.error} />
|
|
<Button variant="primary/medium" onClick={() => window.close()} className="w-full">
|
|
Close
|
|
</Button>
|
|
</MainCenteredContainer>
|
|
</BackgroundWrapper>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AppContainer className="bg-background-deep">
|
|
<BackgroundWrapper>
|
|
<MainCenteredContainer
|
|
variant="onboarding"
|
|
className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
|
|
>
|
|
<FormTitle
|
|
LeadingIcon={<BuildingOfficeIcon className="size-7 text-indigo-500" />}
|
|
title="Select Organization"
|
|
description="Choose which organization to install the Vercel integration into."
|
|
/>
|
|
<Form method="post">
|
|
<input type="hidden" name="action" value="select-org" />
|
|
<input type="hidden" name="code" value={data.code} />
|
|
{data.configurationId && (
|
|
<input type="hidden" name="configurationId" value={data.configurationId} />
|
|
)}
|
|
{data.next && <input type="hidden" name="next" value={data.next} />}
|
|
|
|
<Fieldset>
|
|
<InputGroup>
|
|
<Label>Organization</Label>
|
|
<Select
|
|
name="organizationId"
|
|
placeholder="Choose an organization"
|
|
required
|
|
variant="tertiary/medium"
|
|
dropdownIcon
|
|
defaultValue={data.organizations[0]?.id}
|
|
text={(v) =>
|
|
typeof v === "string"
|
|
? data.organizations.find((o) => o.id === v)?.title ||
|
|
"Choose an organization"
|
|
: "Choose an organization"
|
|
}
|
|
>
|
|
{data.organizations.map((org) => (
|
|
<SelectItem key={org.id} value={org.id}>
|
|
{org.title}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</InputGroup>
|
|
<FormButtons
|
|
confirmButton={
|
|
<div className="flex items-center gap-2">
|
|
<LinkButton to={newOrgUrl} variant="tertiary/small">
|
|
+ New Organization
|
|
</LinkButton>
|
|
<Button type="submit" variant="primary/small">
|
|
Continue
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</Form>
|
|
</MainCenteredContainer>
|
|
</BackgroundWrapper>
|
|
</AppContainer>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AppContainer className="bg-background-deep">
|
|
<BackgroundWrapper>
|
|
<MainCenteredContainer
|
|
variant="onboarding"
|
|
className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg"
|
|
>
|
|
<FormTitle
|
|
LeadingIcon={<FolderIcon className="size-7 text-indigo-500" />}
|
|
title="Select Project"
|
|
description={`Choose which project in "${data.organization.title}" to install the Vercel integration into.`}
|
|
/>
|
|
<Form method="post" onSubmit={() => setIsInstalling(true)}>
|
|
<input type="hidden" name="action" value="select-project" />
|
|
<input type="hidden" name="organizationId" value={data.organization.id} />
|
|
<input type="hidden" name="code" value={data.code} />
|
|
{data.configurationId && (
|
|
<input type="hidden" name="configurationId" value={data.configurationId} />
|
|
)}
|
|
{data.next && <input type="hidden" name="next" value={data.next} />}
|
|
|
|
<Fieldset>
|
|
<InputGroup>
|
|
<Label>Project</Label>
|
|
<Select
|
|
name="projectId"
|
|
placeholder="Choose a project"
|
|
required
|
|
variant="tertiary/medium"
|
|
dropdownIcon
|
|
defaultValue={data.organization.projects[0]?.id}
|
|
text={(v) =>
|
|
typeof v === "string"
|
|
? data.organization.projects.find((p) => p.id === v)?.name ||
|
|
"Choose a project"
|
|
: "Choose a project"
|
|
}
|
|
>
|
|
{data.organization.projects.map((project) => (
|
|
<SelectItem key={project.id} value={project.id}>
|
|
{project.name}
|
|
</SelectItem>
|
|
))}
|
|
</Select>
|
|
</InputGroup>
|
|
<FormButtons
|
|
confirmButton={
|
|
<div className="flex items-center gap-2">
|
|
<LinkButton to={newProjectUrl} variant="tertiary/small" disabled={isLoading}>
|
|
+ New Project
|
|
</LinkButton>
|
|
<Button
|
|
type="submit"
|
|
variant="primary/small"
|
|
disabled={isLoading}
|
|
TrailingIcon={isLoading ? ButtonSpinner : undefined}
|
|
>
|
|
{isLoading ? "Continuing…" : "Continue"}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
</Fieldset>
|
|
</Form>
|
|
</MainCenteredContainer>
|
|
</BackgroundWrapper>
|
|
</AppContainer>
|
|
);
|
|
}
|