413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
const navigation = useNavigation();
|
|
const isUninstalling =
|
|
navigation.state === "submitting" && navigation.formData?.get("intent") === "uninstall";
|
|
|
|
if (!vercelIntegration) {
|
|
return (
|
|
<PageContainer>
|
|
<PageBody>
|
|
<div className="flex flex-col items-center justify-center py-8">
|
|
<Header1>No Vercel Integration Found</Header1>
|
|
<Paragraph className="mt-2 text-center text-text-dimmed">
|
|
This organization doesn't have a Vercel integration configured.
|
|
</Paragraph>
|
|
</div>
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageBody>
|
|
<div className="mb-8">
|
|
<Header1>Vercel Integration</Header1>
|
|
<Paragraph className="mt-2 text-text-dimmed">
|
|
Manage your organization's Vercel integration and connected projects.
|
|
</Paragraph>
|
|
</div>
|
|
|
|
{/* Integration Info Section */}
|
|
<div className="mb-8 rounded-lg border border-grid-bright bg-background-bright p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-medium text-text-bright">Integration Details</h2>
|
|
<div className="mt-2 space-y-1 text-sm text-text-dimmed">
|
|
{teamId && (
|
|
<div>
|
|
<span className="font-medium">Vercel Team ID:</span> {teamId}
|
|
</div>
|
|
)}
|
|
{installationId && (
|
|
<div>
|
|
<span className="font-medium">Installation ID:</span> {installationId}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="font-medium">Installed:</span>{" "}
|
|
{formatDate(new Date(vercelIntegration.createdAt))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="danger/medium"
|
|
LeadingIcon={TrashIcon}
|
|
disabled={isUninstalling || !canManageVercel}
|
|
tooltip={
|
|
canManageVercel
|
|
? undefined
|
|
: "You don't have permission to manage the Vercel integration"
|
|
}
|
|
>
|
|
Remove Integration
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Remove Vercel Integration</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogDescription>
|
|
This will permanently remove the Vercel integration and disconnect all projects.
|
|
This action cannot be undone.
|
|
</DialogDescription>
|
|
<FormButtons
|
|
confirmButton={
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="uninstall" />
|
|
<Button
|
|
variant="danger/medium"
|
|
LeadingIcon={TrashIcon}
|
|
type="submit"
|
|
disabled={isUninstalling}
|
|
>
|
|
{isUninstalling ? "Removing..." : "Remove Integration"}
|
|
</Button>
|
|
</Form>
|
|
}
|
|
cancelButton={
|
|
<DialogClose asChild>
|
|
<Button variant="tertiary/medium">Cancel</Button>
|
|
</DialogClose>
|
|
}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
{actionData?.error && (
|
|
<Paragraph variant="small" className="text-error">
|
|
{actionData.error}
|
|
</Paragraph>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connected Projects Section */}
|
|
<div>
|
|
<h2 className="mb-4 text-lg font-medium text-text-bright">
|
|
Connected Projects ({connectedProjects.length})
|
|
</h2>
|
|
|
|
{connectedProjects.length === 0 ? (
|
|
<div className="rounded-lg border border-grid-bright bg-background-bright p-6 text-center">
|
|
<Paragraph className="text-text-dimmed">
|
|
No projects are currently connected to this Vercel integration.
|
|
</Paragraph>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHeaderCell>Project Name</TableHeaderCell>
|
|
<TableHeaderCell>Vercel Project ID</TableHeaderCell>
|
|
<TableHeaderCell>Connected</TableHeaderCell>
|
|
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{connectedProjects.map((projectIntegration) => (
|
|
<TableRow key={projectIntegration.id}>
|
|
<TableCell>{projectIntegration.project.name}</TableCell>
|
|
<TableCell className="font-mono text-xs">
|
|
{projectIntegration.externalEntityId}
|
|
</TableCell>
|
|
<TableCell>{formatDate(new Date(projectIntegration.createdAt))}</TableCell>
|
|
<TableCell>
|
|
<LinkButton
|
|
variant="minimal/small"
|
|
to={v3ProjectSettingsIntegrationsPath(
|
|
organization,
|
|
projectIntegration.project,
|
|
{ slug: "prod" } // Default to production environment
|
|
)}
|
|
>
|
|
Configure
|
|
</LinkButton>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</PageBody>
|
|
</PageContainer>
|
|
);
|
|
}
|