import { TrashIcon } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { fromPromise } from "neverthrow"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; import { Header1 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { $transaction, prisma } from "~/db.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { VercelIntegrationRepository } from "~/models/vercelIntegration.server"; import { logger } from "~/services/logger.server"; import { requireOrganization } from "~/services/org.server"; import { rbac } from "~/services/rbac.server"; import { dashboardAction } from "~/services/routeBuilders/dashboardBuilder"; import { OrganizationParamsSchema, v3ProjectSettingsIntegrationsPath } from "~/utils/pathBuilder"; function formatDate(date: Date): string { return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true, }).format(date); } export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); const url = new URL(request.url); const configurationId = url.searchParams.get("configurationId") ?? undefined; const { organization, userId } = await requireOrganization(request, organizationSlug); // Display flag for the Remove Integration control — the action enforces // write:vercel independently. Permissive in OSS. const sessionAuth = await rbac.authenticateSession(request, { userId, organizationId: organization.id, }); const canManageVercel = sessionAuth.ok ? sessionAuth.ability.can("write", { type: "vercel" }) : true; // Find Vercel integration for this organization let vercelIntegration = await prisma.organizationIntegration.findFirst({ where: { organizationId: organization.id, service: "VERCEL", deletedAt: null, // If configurationId is provided, filter by it in integrationData ...(configurationId && { integrationData: { path: ["installationId"], equals: configurationId, }, }), }, include: { tokenReference: true, }, }); if (!vercelIntegration) { return typedjson({ organization, vercelIntegration: null, connectedProjects: [], teamId: null, installationId: null, canManageVercel, }); } // Get team ID from integrationData const integrationData = vercelIntegration.integrationData as any; const teamId = integrationData?.teamId ?? null; const installationId = integrationData?.installationId ?? null; // Get all connected projects for this integration const connectedProjects = await prisma.organizationProjectIntegration.findMany({ where: { organizationIntegrationId: vercelIntegration.id, deletedAt: null, }, include: { project: { select: { id: true, slug: true, name: true, }, }, }, orderBy: { createdAt: "desc", }, }); return typedjson({ organization, vercelIntegration, connectedProjects, teamId, installationId, canManageVercel, }); }; const ActionSchema = z.object({ intent: z.literal("uninstall"), }); export const action = dashboardAction( { params: OrganizationParamsSchema, context: async (params) => { const organizationId = await resolveOrgIdFromSlug(params.organizationSlug); return organizationId ? { organizationId } : {}; }, authorization: { action: "write", resource: { type: "vercel" } }, }, async ({ request, params }) => { const { organizationSlug } = params; const { organization, userId } = await requireOrganization(request, organizationSlug); const formData = await request.formData(); const result = ActionSchema.safeParse({ intent: formData.get("intent") }); if (!result.success) { return json({ error: "Invalid action" }, { status: 400 }); } // Find Vercel integration const vercelIntegration = await prisma.organizationIntegration.findFirst({ where: { organizationId: organization.id, service: "VERCEL", deletedAt: null, }, include: { tokenReference: true, }, }); if (!vercelIntegration) { return json({ error: "Vercel integration not found" }, { status: 404 }); } // Uninstall from Vercel side const uninstallResult = await VercelIntegrationRepository.uninstallVercelIntegration(vercelIntegration); if (uninstallResult.isErr()) { logger.error("Failed to uninstall Vercel integration", { organizationId: organization.id, organizationSlug, userId, integrationId: vercelIntegration.id, error: uninstallResult.error.message, }); return json( { error: "Failed to uninstall Vercel integration. Please try again." }, { status: 500 } ); } // Soft-delete the integration and all connected projects in a transaction const txResult = await fromPromise( $transaction(prisma, async (tx) => { await tx.organizationProjectIntegration.updateMany({ where: { organizationIntegrationId: vercelIntegration.id, deletedAt: null, }, data: { deletedAt: new Date() }, }); await tx.organizationIntegration.update({ where: { id: vercelIntegration.id }, data: { deletedAt: new Date() }, }); }), (error) => error ); if (txResult.isErr()) { logger.error("Failed to soft-delete Vercel integration records", { organizationId: organization.id, organizationSlug, userId, integrationId: vercelIntegration.id, error: txResult.error instanceof Error ? txResult.error.message : String(txResult.error), }); return json( { error: "Failed to uninstall Vercel integration. Please try again." }, { status: 500 } ); } if (uninstallResult.value.authInvalid) { logger.warn("Vercel integration uninstalled with auth error - token invalid", { organizationId: organization.id, organizationSlug, userId, integrationId: vercelIntegration.id, }); } else { logger.info("Vercel integration uninstalled successfully", { organizationId: organization.id, organizationSlug, userId, integrationId: vercelIntegration.id, }); } // Redirect back to organization settings return redirect(`/orgs/${organizationSlug}/settings`); } ); export default function VercelIntegrationPage() { const { organization, vercelIntegration, connectedProjects, teamId, installationId, canManageVercel, } = useTypedLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const isUninstalling = navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall"; if (!vercelIntegration) { return (
No Vercel Integration Found This organization doesn't have a Vercel integration configured.
); } return (
Vercel Integration Manage your organization's Vercel integration and connected projects.
{/* Integration Info Section */}

Integration Details

{teamId && (
Vercel Team ID: {teamId}
)} {installationId && (
Installation ID: {installationId}
)}
Installed:{" "} {formatDate(new Date(vercelIntegration.createdAt))}
Remove Vercel Integration This will permanently remove the Vercel integration and disconnect all projects. This action cannot be undone. } cancelButton={ } /> {actionData?.error && ( {actionData.error} )}
{/* Connected Projects Section */}

Connected Projects ({connectedProjects.length})

{connectedProjects.length === 0 ? (
No projects are currently connected to this Vercel integration.
) : ( Project Name Vercel Project ID Connected Actions {connectedProjects.map((projectIntegration) => ( {projectIntegration.project.name} {projectIntegration.externalEntityId} {formatDate(new Date(projectIntegration.createdAt))} Configure ))}
)}
); }