Feat: added integration connect and mcp oAuth

This commit is contained in:
Harshith Mullapudi 2025-07-16 17:36:17 +05:30
parent 30e5462e14
commit f2b4a5f64a
54 changed files with 1635 additions and 10440 deletions

View File

@ -142,7 +142,7 @@ export const ConversationList = ({
className={cn(
"border-border h-auto w-full justify-start rounded p-2 py-1 text-left",
currentConversationId === conversation.id &&
"bg-accent text-accent-foreground font-semibold",
"bg-accent font-semibold",
)}
onClick={() => {
navigate(`/home/conversation/${conversation.id}`);
@ -155,7 +155,7 @@ export const ConversationList = ({
<div className="flex w-full items-start space-x-3">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={cn("truncate font-normal")}>
<p className={cn("text-foreground truncate font-normal")}>
{conversation.title || "Untitled Conversation"}
</p>
</div>

View File

@ -5,14 +5,16 @@ import {
RiSlackFill,
} from "@remixicon/react";
import { LayoutGrid } from "lucide-react";
import { LinearIcon, SlackIcon } from "./icons";
export const ICON_MAPPING = {
slack: RiSlackFill,
slack: SlackIcon,
email: RiMailFill,
discord: RiDiscordFill,
github: RiGithubFill,
gmail: RiMailFill,
linear: LinearIcon,
// Default icon
integration: LayoutGrid,

View File

@ -0,0 +1,2 @@
export * from "./slack-icon";
export * from "./linear-icon";

View File

@ -0,0 +1,23 @@
import type { IconProps } from "./types";
export function LinearIcon({ size = 18, className }: IconProps) {
return (
<svg
width={size}
height={size}
className={className}
viewBox="0 0 256 255.999966"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid"
>
<title>Linear</title>
<g>
<path
d="M8.17351507,102.61269 L153.38731,247.826434 C155.506492,249.945871 154.484163,253.546047 151.537796,254.096394 C146.624112,255.014493 141.611832,255.651912 136.517857,255.991749 C135.430224,256.064224 134.367944,255.654216 133.5971,254.883372 L1.11657404,122.4029 C0.345783548,121.632056 -0.0643290124,120.569776 0.0082341699,119.482143 C0.348078154,114.388168 0.985591977,109.375888 1.9035369,104.462204 C2.45395044,101.515837 6.05412868,100.493508 8.17351507,102.61269 Z M4.08163806,161.409157 C3.11270474,157.795152 7.38226007,155.515399 10.0279718,158.161111 L97.8388892,245.972054 C100.484601,248.617766 98.2048481,252.887372 94.5908433,251.918311 C50.5602697,240.113381 15.8867212,205.43973 4.08163806,161.409157 Z M16.8085843,64.1644626 C18.0417047,62.028635 20.9559826,61.7013463 22.6999089,63.4453494 L192.554651,233.299989 C194.298654,235.043992 193.971621,237.958347 191.835537,239.191441 C188.24202,241.266062 184.538382,243.171405 180.73538,244.896457 C179.304068,245.545401 177.625881,245.218112 176.514687,244.106918 L11.8930308,79.4853127 C10.7818114,78.3741189 10.4544971,76.695932 11.1036452,75.2646202 C12.828595,71.4616177 14.7338864,67.7579799 16.8085843,64.1644626 Z M127.860072,0 C198.629979,0 256,57.3702771 256,128.139928 C256,165.709238 239.831733,199.502437 214.07401,222.940712 C212.587126,224.293659 210.305837,224.204026 208.884257,222.782702 L33.2172979,47.1157434 C31.7959738,45.6941631 31.7063407,43.4128738 33.0592877,41.9259895 C56.4975633,16.1681897 90.2907616,0 127.860072,0 Z"
fill="#222326"
></path>
</g>
</svg>
);
}

View File

@ -0,0 +1,30 @@
import type { IconProps } from "./types";
export function SlackIcon({ size = 18, className }: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 127 127"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
fill="#E01E5A"
/>
<path
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
fill="#36C5F0"
/>
<path
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
fill="#2EB67D"
/>
<path
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
fill="#ECB22E"
/>
</svg>
);
}

View File

@ -0,0 +1,6 @@
export interface IconProps {
size?: number;
className?: string;
color?: string;
onClick?: (event: MouseEvent) => void;
}

View File

@ -1,248 +0,0 @@
import React, { useState, useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { FormButtons } from "~/components/ui/FormButtons";
interface IntegrationAuthDialogProps {
integration: {
id: string;
name: string;
description?: string;
spec: any;
};
children: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
function parseSpec(spec: any) {
if (!spec) return {};
if (typeof spec === "string") {
try {
return JSON.parse(spec);
} catch {
return {};
}
}
return spec;
}
export function IntegrationAuthDialog({
integration,
children,
onOpenChange,
}: IntegrationAuthDialogProps) {
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
const apiKeyFetcher = useFetcher();
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const mcpFetcher = useFetcher<{ redirectURL: string }>();
const specData = parseSpec(integration.spec);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const handleApiKeyConnect = useCallback(() => {
if (!apiKey.trim()) return;
setIsLoading(true);
apiKeyFetcher.submit(
{
integrationDefinitionId: integration.id,
apiKey,
},
{
method: "post",
action: "/api/v1/integration_account",
encType: "application/json",
},
);
}, [integration.id, apiKey, apiKeyFetcher]);
const handleOAuthConnect = useCallback(() => {
setIsConnecting(true);
oauthFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, oauthFetcher]);
const handleMCPConnect = useCallback(() => {
setIsMCPConnecting(true);
mcpFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
mcp: true,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, mcpFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (apiKeyFetcher.state === "idle" && isLoading) {
if (apiKeyFetcher.data !== undefined) {
window.location.reload();
}
}
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
React.useEffect(() => {
if (oauthFetcher.state === "idle" && isConnecting) {
if (oauthFetcher.data?.redirectURL) {
window.location.href = oauthFetcher.data.redirectURL;
} else {
setIsConnecting(false);
}
}
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
React.useEffect(() => {
if (mcpFetcher.state === "idle" && isMCPConnecting) {
if (mcpFetcher.data?.redirectURL) {
window.location.href = mcpFetcher.data.redirectURL;
} else {
setIsMCPConnecting(false);
}
}
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
return (
<Dialog
onOpenChange={(open) => {
if (open) {
setApiKey("");
}
onOpenChange?.(open);
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="p-4 sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect to {integration.name}</DialogTitle>
<DialogDescription>
{integration.description ||
`Connect your ${integration.name} account to enable integration.`}
</DialogDescription>
</DialogHeader>
{/* API Key Authentication */}
{hasApiKey && (
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="apiKey" className="text-sm font-medium">
{specData?.auth?.api_key?.label || "API Key"}
</label>
<Input
id="apiKey"
type="password"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{specData?.auth?.api_key?.description && (
<p className="text-muted-foreground text-xs">
{specData.auth.api_key.description}
</p>
)}
</div>
<FormButtons
confirmButton={
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading || apiKeyFetcher.state === "submitting"
? "Connecting..."
: "Connect"}
</Button>
}
/>
</div>
)}
{/* OAuth Authentication */}
{hasOAuth2 && (
<div className="flex justify-center py-4">
<Button
type="button"
variant="default"
size="lg"
disabled={isConnecting || oauthFetcher.state === "submitting"}
onClick={handleOAuthConnect}
>
{isConnecting || oauthFetcher.state === "submitting"
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
)}
{/* MCP Authentication */}
{hasMCPAuth && (
<div className="space-y-4 py-4">
<div className="border-t pt-4">
<h4 className="text-sm font-medium mb-2">MCP Authentication</h4>
<p className="text-muted-foreground text-xs mb-4">
This integration requires MCP (Model Context Protocol) authentication.
</p>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
onClick={handleMCPConnect}
>
{isMCPConnecting || mcpFetcher.state === "submitting"
? "Connecting..."
: `Connect via MCP`}
</Button>
</div>
</div>
)}
{/* No authentication method found */}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
<div className="text-muted-foreground py-4 text-center">
This integration doesn't specify an authentication method.
</div>
)}
<DialogFooter className="sm:justify-start">
<div className="text-muted-foreground w-full text-xs">
By connecting, you agree to the {integration.name} terms of service.
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,68 @@
import React, { useCallback } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Check } from "lucide-react";
interface ConnectedAccountSectionProps {
activeAccount?: {
id: string;
createdAt: string;
};
}
export function ConnectedAccountSection({
activeAccount,
}: ConnectedAccountSectionProps) {
const disconnectFetcher = useFetcher();
const handleDisconnect = useCallback(() => {
if (!activeAccount?.id) return;
disconnectFetcher.submit(
{
integrationAccountId: activeAccount.id,
},
{
method: "post",
action: "/api/v1/integration_account/disconnect",
encType: "application/json",
},
);
}, [activeAccount?.id, disconnectFetcher]);
React.useEffect(() => {
if (disconnectFetcher.state === "idle" && disconnectFetcher.data) {
window.location.reload();
}
}, [disconnectFetcher.state, disconnectFetcher.data]);
if (!activeAccount) return null;
return (
<div className="mt-6 space-y-2">
<h3 className="text-lg font-medium">Connected Account</h3>
<div className="bg-background-3 rounded-lg p-4">
<div className="text-sm">
<p className="inline-flex items-center gap-2 font-medium">
<Check size={16} /> Account ID: {activeAccount.id}
</p>
<p className="text-muted-foreground mb-3">
Connected on{" "}
{new Date(activeAccount.createdAt).toLocaleDateString()}
</p>
<div className="flex w-full justify-end">
<Button
variant="destructive"
disabled={disconnectFetcher.state === "submitting"}
onClick={handleDisconnect}
>
{disconnectFetcher.state === "submitting"
? "Disconnecting..."
: "Disconnect"}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@ -9,6 +9,7 @@ import {
CardTitle,
} from "~/components/ui/card";
import { getIcon, type IconType } from "~/components/icon-utils";
import { Badge } from "../ui/badge";
interface IntegrationCardProps {
integration: {
@ -19,26 +20,20 @@ interface IntegrationCardProps {
slug?: string;
};
isConnected: boolean;
onClick?: () => void;
showDetail?: boolean;
}
export function IntegrationCard({
integration,
isConnected,
onClick,
showDetail = false,
}: IntegrationCardProps) {
const Component = getIcon(integration.icon as IconType);
const CardWrapper = showDetail ? Link : "div";
const cardProps = showDetail
? { to: `/home/integration/${integration.slug || integration.id}` }
: { onClick, className: "cursor-pointer" };
return (
<CardWrapper {...cardProps}>
<Card className="transition-all hover:shadow-md">
<Link
to={`/home/integration/${integration.slug || integration.id}`}
className="bg-background-3 h-full rounded-lg"
>
<Card className="transition-all">
<CardHeader className="p-4">
<div className="bg-background-2 mb-2 flex h-6 w-6 items-center justify-center rounded">
<Component size={18} />
@ -51,13 +46,13 @@ export function IntegrationCard({
{isConnected && (
<CardFooter className="p-3">
<div className="flex w-full items-center justify-end">
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-800">
<Badge className="h-6 rounded bg-green-100 p-2 text-xs text-green-800">
Connected
</span>
</Badge>
</div>
</CardFooter>
)}
</Card>
</CardWrapper>
</Link>
);
}
}

View File

@ -1,7 +1,6 @@
import React, { useMemo } from "react";
import { Search } from "lucide-react";
import { IntegrationCard } from "./IntegrationCard";
import { IntegrationAuthDialog } from "./IntegrationAuthDialog";
import { IntegrationCard } from "./integration-card";
interface IntegrationGridProps {
integrations: Array<{
@ -19,7 +18,6 @@ interface IntegrationGridProps {
export function IntegrationGrid({
integrations,
activeAccountIds,
showDetail = false,
}: IntegrationGridProps) {
const hasActiveAccount = (integrationDefinitionId: string) =>
activeAccountIds.has(integrationDefinitionId);
@ -38,29 +36,13 @@ export function IntegrationGrid({
{integrations.map((integration) => {
const isConnected = hasActiveAccount(integration.id);
if (showDetail) {
return (
<IntegrationCard
key={integration.id}
integration={integration}
isConnected={isConnected}
showDetail={true}
/>
);
}
return (
<IntegrationAuthDialog
key={integration.id}
<IntegrationCard
integration={integration}
>
<IntegrationCard
integration={integration}
isConnected={isConnected}
/>
</IntegrationAuthDialog>
isConnected={isConnected}
/>
);
})}
</div>
);
}
}

View File

@ -0,0 +1,133 @@
import React, { useCallback, useState } from "react";
import { useFetcher } from "@remix-run/react";
import { Button } from "~/components/ui/button";
import { Check } from "lucide-react";
interface MCPAuthSectionProps {
integration: {
id: string;
name: string;
};
activeAccount?: {
id: string;
integrationConfiguration?: {
mcp?: any;
};
};
hasMCPAuth: boolean;
}
export function MCPAuthSection({
integration,
activeAccount,
hasMCPAuth,
}: MCPAuthSectionProps) {
const [isMCPConnecting, setIsMCPConnecting] = useState(false);
const mcpFetcher = useFetcher<{ redirectURL: string }>();
const disconnectMcpFetcher = useFetcher();
const isMCPConnected = activeAccount?.integrationConfiguration?.mcp;
const handleMCPConnect = useCallback(() => {
setIsMCPConnecting(true);
mcpFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
integrationAccountId: activeAccount?.id as string,
mcp: true,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, mcpFetcher]);
const handleMCPDisconnect = useCallback(() => {
if (!activeAccount?.id) return;
disconnectMcpFetcher.submit(
{
integrationAccountId: activeAccount.id,
},
{
method: "post",
action: "/api/v1/integration_account/disconnect_mcp",
encType: "application/json",
},
);
}, [activeAccount?.id, disconnectMcpFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (mcpFetcher.state === "idle" && isMCPConnecting) {
if (mcpFetcher.data?.redirectURL) {
window.location.href = mcpFetcher.data.redirectURL;
} else {
setIsMCPConnecting(false);
}
}
}, [mcpFetcher.state, mcpFetcher.data, isMCPConnecting]);
React.useEffect(() => {
if (disconnectMcpFetcher.state === "idle" && disconnectMcpFetcher.data) {
window.location.reload();
}
}, [disconnectMcpFetcher.state, disconnectMcpFetcher.data]);
if (!hasMCPAuth || !activeAccount) return null;
return (
<div className="mt-6 space-y-2">
<h3 className="text-lg font-medium">MCP Authentication</h3>
{isMCPConnected ? (
<div className="bg-background-3 rounded-lg p-4">
<div className="text-sm">
<p className="inline-flex items-center gap-2 font-medium">
<Check size={16} /> MCP Connected
</p>
<p className="text-muted-foreground mb-3">
MCP (Model Context Protocol) authentication is active
</p>
<div className="flex w-full justify-end">
<Button
variant="destructive"
disabled={disconnectMcpFetcher.state === "submitting"}
onClick={handleMCPDisconnect}
>
{disconnectMcpFetcher.state === "submitting"
? "Disconnecting..."
: "Disconnect"}
</Button>
</div>
</div>
</div>
) : activeAccount ? (
<div className="bg-background-3 rounded-lg p-4">
<h4 className="text-md mb-1 font-medium">
MCP (Model Context Protocol) Authentication
</h4>
<p className="text-muted-foreground mb-3 text-sm">
This integration requires MCP (Model Context Protocol)
authentication. Please provide the required MCP credentials in
addition to any other authentication method.
</p>
<div className="flex w-full justify-end">
<Button
variant="secondary"
disabled={isMCPConnecting || mcpFetcher.state === "submitting"}
onClick={handleMCPConnect}
>
{isMCPConnecting || mcpFetcher.state === "submitting"
? "Connecting..."
: `Connect for MCP`}
</Button>
</div>
</div>
) : null}
</div>
);
}

View File

@ -48,37 +48,20 @@ function LogItemRenderer(
);
}
const getStatusIcon = (status: string) => {
switch (status) {
case "PROCESSING":
return <Clock className="h-4 w-4 text-blue-500" />;
case "PENDING":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "COMPLETED":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "FAILED":
return <XCircle className="h-4 w-4 text-red-500" />;
case "CANCELLED":
return <XCircle className="h-4 w-4 text-gray-500" />;
default:
return <AlertCircle className="h-4 w-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "PROCESSING":
return "bg-blue-100 text-blue-800";
return "bg-blue-100 text-blue-800 hover:bg-blue-100 hover:text-blue-800";
case "PENDING":
return "bg-yellow-100 text-yellow-800";
return "bg-yellow-100 text-yellow-800 hover:bg-yellow-100 hover:text-yellow-800";
case "COMPLETED":
return "bg-green-100 text-green-800";
return "bg-green-100 text-green-800 hover:bg-green-100 hover:text-green-800";
case "FAILED":
return "bg-red-100 text-red-800";
return "bg-red-100 text-red-800 hover:bg-red-100 hover:text-red-800";
case "CANCELLED":
return "bg-gray-100 text-gray-800";
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
default:
return "bg-gray-100 text-gray-800";
return "bg-gray-100 text-gray-800 hover:bg-gray-100 hover:text-gray-800";
}
};
@ -95,12 +78,16 @@ function LogItemRenderer(
<CardContent className="p-4">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<Badge variant="secondary" className="rounded text-xs">
{log.source}
</Badge>
<div className="flex items-center gap-1">
{getStatusIcon(log.status)}
<Badge className={cn("text-xs", getStatusColor(log.status))}>
<Badge
className={cn(
"rounded text-xs",
getStatusColor(log.status),
)}
>
{log.status.toLowerCase()}
</Badge>
</div>
@ -111,21 +98,11 @@ function LogItemRenderer(
</div>
<div className="mb-2">
<p className="text-sm text-gray-700">{log.ingestText}</p>
<p className="text-foreground text-sm">{log.ingestText}</p>
</div>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
{log.sourceURL && (
<a
href={log.sourceURL}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
Source URL
</a>
)}
{log.processedAt && (
<span>
Processed: {new Date(log.processedAt).toLocaleString()}

View File

@ -52,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<div className="mt-1 flex w-full items-center justify-start gap-2">
<div className="mt-1 ml-1 flex w-full items-center justify-start gap-2">
<Logo width={20} height={20} />
C.O.R.E.
</div>

View File

@ -30,7 +30,7 @@ export const NavMain = ({
<Button
isActive={location.pathname.includes(item.url)}
className={cn(
"bg-grayAlpha-100 w-fit gap-1 !rounded-md",
"bg-grayAlpha-100 text-foreground w-fit gap-1 !rounded-md",
location.pathname.includes(item.url) &&
"!bg-accent !text-accent-foreground",
)}

View File

@ -8,10 +8,10 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-none bg-grayAlpha-100",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground bg-background",
},
},

View File

@ -1,8 +1,8 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "@radix-ui/react-icons";
import React from "react";
import { cn } from '../../lib/utils';
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'checkbox peer h-4 w-4 shrink-0 rounded-sm border-1 border-border-dark focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground',
"checkbox peer border-border-dark focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-[5px] border-1 focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('flex items-center text-white justify-center')}
className={cn("flex items-center justify-center text-white")}
>
<CheckIcon className="h-3 w-3" />
</CheckboxPrimitive.Indicator>

View File

@ -1,8 +1,7 @@
import { useLocation, useNavigate } from "@remix-run/react";
import { Button } from "./button";
import { Plus } from "lucide-react";
import { ArrowLeft, ArrowRight, Plus } from "lucide-react";
import { SidebarTrigger } from "./sidebar";
import React from "react";
const PAGE_TITLES: Record<string, string> = {
"/home/dashboard": "Memory graph",
@ -50,6 +49,36 @@ function getLogsTab(pathname: string): "all" | "activity" {
return "all";
}
// Back and Forward navigation component
function NavigationBackForward() {
const navigate = useNavigate();
return (
<div className="mr-1 flex items-center">
<Button
variant="ghost"
size="xs"
aria-label="Back"
onClick={() => navigate(-1)}
className="rounded"
type="button"
>
<ArrowLeft size={16} />
</Button>
<Button
variant="ghost"
size="xs"
aria-label="Forward"
onClick={() => navigate(1)}
className="rounded"
type="button"
>
<ArrowRight size={16} />
</Button>
</div>
);
}
export function SiteHeader() {
const location = useLocation();
const navigate = useNavigate();
@ -72,8 +101,10 @@ export function SiteHeader() {
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b border-gray-300 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center justify-between gap-1 px-4 pr-2 lg:gap-2">
<div className="flex items-center gap-1">
<SidebarTrigger className="-ml-1" />
<div className="-ml-1 flex items-center gap-1">
{/* Back/Forward navigation before SidebarTrigger */}
<NavigationBackForward />
<SidebarTrigger className="mr-1" />
<h1 className="text-base">{title}</h1>

View File

@ -0,0 +1,51 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
try {
const userId = await requireUserId(request);
const body = await request.json();
const { integrationAccountId } = body;
if (!integrationAccountId) {
return json(
{ error: "Integration account ID is required" },
{ status: 400 },
);
}
// Soft delete the integration account by setting deletedAt
const updatedAccount = await prisma.integrationAccount.delete({
where: {
id: integrationAccountId,
deleted: null,
},
});
logger.info("Integration account disconnected (soft deleted)", {
integrationAccountId,
userId,
});
return json({
success: true,
message: "Integration account disconnected successfully",
});
} catch (error) {
logger.error("Failed to disconnect integration account", {
error: error instanceof Error ? error.message : "Unknown error",
});
return json(
{ error: "Failed to disconnect integration account" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,75 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/services/session.server";
import { logger } from "~/services/logger.service";
import { prisma } from "~/db.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
return json({ error: "Method not allowed" }, { status: 405 });
}
try {
const userId = await requireUserId(request);
const body = await request.json();
const { integrationAccountId } = body;
if (!integrationAccountId) {
return json(
{ error: "Integration account ID is required" },
{ status: 400 },
);
}
// Get the current integration account
const currentAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
deleted: null,
},
});
if (!currentAccount) {
return json({ error: "Integration account not found" }, { status: 404 });
}
// Parse the current configuration
const currentConfig =
(currentAccount.integrationConfiguration as any) || {};
// Remove the mcp key from the configuration
const updatedConfig = { ...currentConfig };
delete updatedConfig.mcp;
// Update the integration account
const updatedAccount = await prisma.integrationAccount.update({
where: {
id: integrationAccountId,
deleted: null,
},
data: {
integrationConfiguration: updatedConfig,
},
});
logger.info("MCP configuration disconnected", {
integrationAccountId,
userId,
});
return json({
success: true,
message: "MCP configuration disconnected successfully",
account: updatedAccount,
});
} catch (error) {
logger.error("Failed to disconnect MCP configuration", {
error: error instanceof Error ? error.message : "Unknown error",
});
return json(
{ error: "Failed to disconnect MCP configuration" },
{ status: 500 },
);
}
}

View File

@ -1,11 +1,11 @@
import { json } from "@remix-run/node";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { createIntegrationAccount } from "~/services/integrationAccount.server";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { IntegrationEventType } from "@core/types";
import { runIntegrationTrigger } from "~/services/integration.server";
import { getIntegrationDefinitionWithId } from "~/services/integrationDefinition.server";
import { logger } from "~/services/logger.service";
import { getWorkspaceByUser } from "~/models/workspace.server";
// Schema for creating an integration account with API key
const IntegrationAccountBodySchema = z.object({
@ -14,30 +14,30 @@ const IntegrationAccountBodySchema = z.object({
});
// Route for creating an integration account directly with an API key
const { action, loader } = createActionApiRoute(
const { action, loader } = createHybridActionApiRoute(
{
body: IntegrationAccountBodySchema,
allowJWT: true,
authorization: {
action: "create",
subject: "IntegrationAccount",
action: "integrationaccount:create",
},
corsStrategy: "all",
},
async ({ body, authentication }) => {
const { integrationDefinitionId, apiKey } = body;
const { userId } = authentication;
const workspace = await getWorkspaceByUser(authentication.userId);
try {
// Get the integration definition
const integrationDefinition = await getIntegrationDefinitionWithId(
integrationDefinitionId
integrationDefinitionId,
);
if (!integrationDefinition) {
return json(
{ error: "Integration definition not found" },
{ status: 404 }
{ status: 404 },
);
}
@ -50,26 +50,18 @@ const { action, loader } = createActionApiRoute(
apiKey,
},
},
userId
userId,
workspace?.id,
);
if (!setupResult || !setupResult.accountId) {
return json(
{ error: "Failed to setup integration with the provided API key" },
{ status: 400 }
{ status: 400 },
);
}
// Create the integration account
const integrationAccount = await createIntegrationAccount({
accountId: setupResult.accountId,
integrationDefinitionId,
userId,
config: setupResult.config || {},
settings: setupResult.settings || {},
});
return json({ success: true, integrationAccount });
return json({ success: true, setupResult });
} catch (error) {
logger.error("Error creating integration account", {
error,
@ -78,10 +70,10 @@ const { action, loader } = createActionApiRoute(
});
return json(
{ error: "Failed to create integration account" },
{ status: 500 }
{ status: 500 },
);
}
}
},
);
export { action, loader };
export { action, loader };

View File

@ -109,6 +109,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const integrationDef =
log.activity?.integrationAccount?.integrationDefinition;
const logData = log.data as any;
return {
id: log.id,
source: integrationDef?.name || logData?.source || "Unknown",

View File

@ -81,21 +81,25 @@ const { action, loader } = createActionApiRoute(
const integrationConfig =
integrationAccount?.integrationConfiguration as any;
if (!integrationAccount || !integrationConfig) {
if (
!integrationAccount ||
!integrationConfig ||
!integrationConfig.mcp
) {
return null;
}
return {
serverUrl,
tokens: {
access_token: integrationConfig.access_token,
token_type: integrationConfig.token_type || "bearer",
expires_in: integrationConfig.expires_in || 3600,
refresh_token: integrationConfig.refresh_token,
scope: integrationConfig.scope || "read write",
access_token: integrationConfig.mcp.tokens.access_token,
token_type: integrationConfig.mcp.tokens.token_type || "bearer",
expires_in: integrationConfig.mcp.tokens.expires_in || 3600,
refresh_token: integrationConfig.mcp.tokens.refresh_token,
scope: integrationConfig.mcp.tokens.scope || "read write",
},
expiresAt: integrationConfig.expiresAt
? new Date(integrationConfig.expiresAt)
expiresAt: integrationConfig.mcp.tokens.expiresAt
? new Date(integrationConfig.mcp.tokens.expiresAt)
: new Date(Date.now() + 3600 * 1000),
};
},

View File

@ -6,6 +6,10 @@ import { createMCPAuthClient } from "@core/mcp-proxy";
import { logger } from "~/services/logger.service";
import { env } from "~/env.server";
import { getIntegrationDefinitionForState } from "~/services/oauth/oauth.server";
import {
getIntegrationAccount,
getIntegrationAccountForId,
} from "~/services/integrationAccount.server";
const CALLBACK_URL = `${env.APP_ORIGIN}/api/v1/oauth/callback`;
const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
@ -26,8 +30,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
});
}
const { integrationDefinitionId, redirectURL } =
await getIntegrationDefinitionForState(state);
const {
integrationDefinitionId,
redirectURL,
userId,
workspaceId,
integrationAccountId,
} = await getIntegrationDefinitionForState(state);
try {
// For now, we'll assume Linear integration - in the future this should be derived from state
@ -35,6 +44,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
integrationDefinitionId,
);
const integrationAccount =
await getIntegrationAccountForId(integrationAccountId);
if (!integrationDefinition) {
throw new Error("Integration definition not found");
}
@ -71,11 +83,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
state,
redirect_uri: MCP_CALLBACK_URL,
},
integrationDefinition,
mcp: true,
},
},
// We need to get userId from somewhere - for now using undefined
undefined,
userId,
workspaceId,
integrationAccount ?? undefined,
);
return new Response(null, {

View File

@ -14,7 +14,7 @@ const { loader } = createActionApiRoute(
for (const [key, value] of url.searchParams.entries()) {
params[key] = value;
}
return await callbackHandler(params, request);
return await callbackHandler(params);
},
);

View File

@ -1,14 +1,22 @@
import React, { useMemo } from "react";
import React, { useMemo, useState, useCallback } from "react";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { useLoaderData, useFetcher } from "@remix-run/react";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationAuthDialog } from "~/components/integrations/IntegrationAuthDialog";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { getIcon, type IconType } from "~/components/icon-utils";
import { ArrowLeft, ExternalLink } from "lucide-react";
import { Checkbox } from "~/components/ui/checkbox";
import { MCPAuthSection } from "~/components/integrations/mcp-auth-section";
import { ConnectedAccountSection } from "~/components/integrations/connected-account-section";
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
@ -21,7 +29,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
]);
const integration = integrationDefinitions.find(
(def) => def.slug === slug || def.id === slug
(def) => def.slug === slug || def.id === slug,
);
if (!integration) {
@ -49,83 +57,119 @@ function parseSpec(spec: any) {
export default function IntegrationDetail() {
const { integration, integrationAccounts } = useLoaderData<typeof loader>();
const [apiKey, setApiKey] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [showApiKeyForm, setShowApiKeyForm] = useState(false);
const apiKeyFetcher = useFetcher();
const oauthFetcher = useFetcher<{ redirectURL: string }>();
const activeAccount = useMemo(
() =>
integrationAccounts.find(
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive
(acc) => acc.integrationDefinitionId === integration.id && acc.isActive,
),
[integrationAccounts, integration.id]
[integrationAccounts, integration.id],
);
const specData = useMemo(() => parseSpec(integration.spec), [integration.spec]);
const specData = useMemo(
() => parseSpec(integration.spec),
[integration.spec],
);
const hasApiKey = !!specData?.auth?.api_key;
const hasOAuth2 = !!specData?.auth?.OAuth2;
const hasMCPAuth = !!specData?.mcpAuth;
const Component = getIcon(integration.icon as IconType);
const handleApiKeyConnect = useCallback(() => {
if (!apiKey.trim()) return;
setIsLoading(true);
apiKeyFetcher.submit(
{
integrationDefinitionId: integration.id,
apiKey,
},
{
method: "post",
action: "/api/v1/integration_account",
encType: "application/json",
},
);
}, [integration.id, apiKey, apiKeyFetcher]);
const handleOAuthConnect = useCallback(() => {
setIsConnecting(true);
oauthFetcher.submit(
{
integrationDefinitionId: integration.id,
redirectURL: window.location.href,
},
{
method: "post",
action: "/api/v1/oauth",
encType: "application/json",
},
);
}, [integration.id, oauthFetcher]);
// Watch for fetcher completion
React.useEffect(() => {
if (apiKeyFetcher.state === "idle" && isLoading) {
if (apiKeyFetcher.data !== undefined) {
window.location.reload();
}
}
}, [apiKeyFetcher.state, apiKeyFetcher.data, isLoading]);
React.useEffect(() => {
if (oauthFetcher.state === "idle" && isConnecting) {
if (oauthFetcher.data?.redirectURL) {
window.location.href = oauthFetcher.data.redirectURL;
} else {
setIsConnecting(false);
}
}
}, [oauthFetcher.state, oauthFetcher.data, isConnecting]);
return (
<div className="home flex h-full flex-col overflow-y-auto p-4 px-5">
{/* Header */}
<div className="mb-6 flex items-center gap-4">
<Link
to="/home/integrations"
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
Back to Integrations
</Link>
</div>
{/* Integration Details */}
<div className="mx-auto max-w-2xl space-y-6">
<div className="mx-auto w-2xl space-y-6">
<Card>
<CardHeader>
<CardHeader className="bg-background-2">
<div className="flex items-start gap-4">
<div className="bg-background-2 flex h-12 w-12 items-center justify-center rounded">
<div className="bg-grayAlpha-100 flex h-12 w-12 items-center justify-center rounded">
<Component size={24} />
</div>
<div className="flex-1">
<div className="-mt-1 flex-1">
<CardTitle className="text-2xl">{integration.name}</CardTitle>
<CardDescription className="mt-2 text-base">
<CardDescription className="text-base">
{integration.description || `Connect to ${integration.name}`}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Connection Status */}
<div className="mb-6 flex items-center gap-3">
<span className="text-sm font-medium">Status:</span>
{activeAccount ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800">
Connected
</span>
) : (
<span className="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700">
Not Connected
</span>
)}
</div>
<CardContent className="bg-background-2 p-4">
{/* Authentication Methods */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Authentication Methods</h3>
<div className="space-y-2">
{hasApiKey && (
<div className="flex items-center gap-2">
<span className="text-sm"> API Key authentication</span>
<span className="inline-flex items-center gap-2 text-sm">
<Checkbox checked /> API Key authentication
</span>
</div>
)}
{hasOAuth2 && (
<div className="flex items-center gap-2">
<span className="text-sm"> OAuth 2.0 authentication</span>
</div>
)}
{hasMCPAuth && (
<div className="flex items-center gap-2">
<span className="text-sm"> MCP (Model Context Protocol) authentication</span>
<span className="inline-flex items-center gap-2 text-sm">
<Checkbox checked />
OAuth 2.0 authentication
</span>
</div>
)}
{!hasApiKey && !hasOAuth2 && !hasMCPAuth && (
@ -136,46 +180,110 @@ export default function IntegrationDetail() {
</div>
</div>
{/* Connect Button */}
{!activeAccount && (hasApiKey || hasOAuth2 || hasMCPAuth) && (
<div className="mt-6 flex justify-center">
<IntegrationAuthDialog integration={integration}>
<Button size="lg" className="px-8">
Connect to {integration.name}
</Button>
</IntegrationAuthDialog>
{/* Connect Section */}
{!activeAccount && (hasApiKey || hasOAuth2) && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">
Connect to {integration.name}
</h3>
{/* API Key Authentication */}
{hasApiKey && (
<div className="bg-background-3 space-y-4 rounded-lg p-4">
<h4 className="font-medium">API Key Authentication</h4>
{!showApiKeyForm ? (
<Button
variant="secondary"
onClick={() => setShowApiKeyForm(true)}
className="w-full"
>
Connect with API Key
</Button>
) : (
<div className="space-y-3">
<div className="space-y-2">
<label
htmlFor="apiKey"
className="text-sm font-medium"
>
{specData?.auth?.api_key?.label || "API Key"}
</label>
<Input
id="apiKey"
placeholder="Enter your API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{specData?.auth?.api_key?.description && (
<p className="text-muted-foreground text-xs">
{specData.auth.api_key.description}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
setShowApiKeyForm(false);
setApiKey("");
}}
>
Cancel
</Button>
<Button
type="button"
variant="default"
disabled={isLoading || !apiKey.trim()}
onClick={handleApiKeyConnect}
>
{isLoading || apiKeyFetcher.state === "submitting"
? "Connecting..."
: "Connect"}
</Button>
</div>
</div>
)}
</div>
)}
{/* OAuth Authentication */}
{hasOAuth2 && (
<div className="bg-background-3 rounded-lg p-4">
<h4 className="mb-3 font-medium">
OAuth 2.0 Authentication
</h4>
<Button
type="button"
variant="secondary"
size="lg"
disabled={
isConnecting || oauthFetcher.state === "submitting"
}
onClick={handleOAuthConnect}
className="w-full"
>
{isConnecting || oauthFetcher.state === "submitting"
? "Connecting..."
: `Connect to ${integration.name}`}
</Button>
</div>
)}
</div>
)}
{/* Connected Account Info */}
{activeAccount && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Connected Account</h3>
<div className="rounded-lg border bg-green-50 p-4">
<div className="text-sm text-green-800">
<p className="font-medium">Account ID: {activeAccount.id}</p>
<p className="text-muted-foreground">
Connected on {new Date(activeAccount.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
)}
<ConnectedAccountSection activeAccount={activeAccount} />
{/* Integration Spec Details */}
{specData && Object.keys(specData).length > 0 && (
<div className="mt-6 space-y-4">
<h3 className="text-lg font-medium">Integration Details</h3>
<div className="rounded-lg border bg-gray-50 p-4">
<pre className="text-sm text-gray-700">
{JSON.stringify(specData, null, 2)}
</pre>
</div>
</div>
)}
{/* MCP Authentication Section */}
<MCPAuthSection
integration={integration}
activeAccount={activeAccount as any}
hasMCPAuth={hasMCPAuth}
/>
</CardContent>
</Card>
</div>
</div>
);
}
}

View File

@ -5,7 +5,7 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { requireUserId, requireWorkpace } from "~/services/session.server";
import { getIntegrationDefinitions } from "~/services/integrationDefinition.server";
import { getIntegrationAccounts } from "~/services/integrationAccount.server";
import { IntegrationGrid } from "~/components/integrations/IntegrationGrid";
import { IntegrationGrid } from "~/components/integrations/integration-grid";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);

View File

@ -2,34 +2,33 @@ import { useState } from "react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
import { AppContainer, PageContainer, PageBody } from "~/components/layout/app-layout";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { AppContainer, PageContainer } from "~/components/layout/app-layout";
import { Card, CardContent } from "~/components/ui/card";
import { Activity } from "lucide-react";
export default function LogsActivity() {
const [selectedSource, setSelectedSource] = useState<string | undefined>();
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
const {
logs,
hasMore,
loadMore,
availableSources,
isLoading,
isInitialLoad
} = useLogs({
endpoint: '/api/v1/logs/activity',
source: selectedSource,
status: selectedStatus
const {
logs,
hasMore,
loadMore,
availableSources,
isLoading,
isInitialLoad,
} = useLogs({
endpoint: "/api/v1/logs/activity",
source: selectedSource,
status: selectedStatus,
});
if (isInitialLoad) {
return (
<AppContainer>
<PageContainer>
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<div className="flex h-64 items-center justify-center">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2"></div>
</div>
</PageContainer>
</AppContainer>
@ -37,80 +36,43 @@ export default function LogsActivity() {
}
return (
<AppContainer>
<PageContainer>
<PageBody>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Activity className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-bold">Activity Ingestion Logs</h1>
<p className="text-muted-foreground">
View ingestion logs for activities from connected integrations
</p>
</div>
</div>
<Badge variant="outline" className="text-sm">
{logs.length} activity logs loaded
</Badge>
</div>
<div className="space-y-6 p-4 px-5">
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
/>
{/* Filters */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Filters</CardTitle>
</CardHeader>
<CardContent>
<LogsFilters
availableSources={availableSources}
selectedSource={selectedSource}
selectedStatus={selectedStatus}
onSourceChange={setSelectedSource}
onStatusChange={setSelectedStatus}
/>
</CardContent>
</Card>
{/* Logs List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Activity Ingestion Queue</h2>
{hasMore && (
<span className="text-sm text-muted-foreground">
Scroll to load more...
</span>
)}
{/* Logs List */}
<div className="space-y-4">
{logs.length === 0 ? (
<Card>
<CardContent className="bg-background-2 flex items-center justify-center py-16">
<div className="text-center">
<Activity className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 text-lg font-semibold">
No activity logs found
</h3>
<p className="text-muted-foreground">
{selectedSource || selectedStatus
? "Try adjusting your filters to see more results."
: "No activity ingestion logs are available yet."}
</p>
</div>
{logs.length === 0 ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
<div className="text-center">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">No activity logs found</h3>
<p className="text-muted-foreground">
{selectedSource || selectedStatus
? 'Try adjusting your filters to see more results.'
: 'No activity ingestion logs are available yet.'}
</p>
</div>
</CardContent>
</Card>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</div>
</PageBody>
</PageContainer>
</AppContainer>
</CardContent>
</Card>
) : (
<VirtualLogsList
logs={logs}
hasMore={hasMore}
loadMore={loadMore}
isLoading={isLoading}
height={600}
/>
)}
</div>
</div>
);
}

View File

@ -1,39 +1,12 @@
import { tasks } from "@trigger.dev/sdk/v3";
import { getOrCreatePersonalAccessToken } from "./personalAccessToken.server";
import { logger } from "./logger.service";
import { type integrationRun } from "~/trigger/integrations/integration-run";
import type { IntegrationDefinitionV2 } from "@core/database";
/**
* Prepares the parameters for triggering an integration.
* If userId is provided, gets or creates a personal access token for the user.
*/
async function prepareIntegrationTrigger(
integrationDefinition: IntegrationDefinitionV2,
userId?: string,
) {
logger.info(`Loading integration ${integrationDefinition.slug}`);
let pat = "";
let patId = "";
if (userId) {
// Use the integration slug as the token name for uniqueness
const tokenResult = await getOrCreatePersonalAccessToken({
name: integrationDefinition.slug ?? "integration",
userId,
});
pat = tokenResult.token ?? "";
patId = tokenResult.id ?? "";
}
return {
integrationDefinition,
pat,
patId,
};
}
import type {
IntegrationAccount,
IntegrationDefinitionV2,
} from "@core/database";
/**
* Triggers an integration run asynchronously.
@ -43,11 +16,24 @@ export async function runIntegrationTriggerAsync(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
) {
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
logger.info(
`Triggering async integration run for ${integrationDefinition.slug}`,
{
integrationId: integrationDefinition.id,
event: event.event,
userId,
workspaceId,
},
);
return await tasks.trigger<typeof integrationRun>("integration-run", {
...params,
event,
integrationDefinition,
event: event.event,
eventBody: event.eventBody,
integrationAccount: event.integrationAccount,
workspaceId,
});
}
@ -59,14 +45,26 @@ export async function runIntegrationTrigger(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: any,
userId?: string,
workspaceId?: string,
integrationAccount?: IntegrationAccount,
) {
const params = await prepareIntegrationTrigger(integrationDefinition, userId);
logger.info(
`Triggering sync integration run for ${integrationDefinition.slug}`,
{
integrationId: integrationDefinition.id,
event: event.event,
userId,
workspaceId,
},
);
const response = await tasks.triggerAndPoll<typeof integrationRun>(
"integration-run",
{
...params,
integrationAccount: event.integrationAccount,
integrationDefinition,
integrationAccount,
workspaceId,
userId,
event: event.event,
eventBody: event.eventBody,
},

View File

@ -13,27 +13,10 @@ export const getIntegrationAccount = async (
});
};
export const createIntegrationAccount = async ({
integrationDefinitionId,
userId,
accountId,
config,
settings,
}: {
integrationDefinitionId: string;
userId: string;
accountId: string;
config?: Record<string, any>;
settings?: Record<string, any>;
}) => {
return prisma.integrationAccount.create({
data: {
accountId,
integrationDefinitionId,
integratedById: userId,
config: config || {},
settings: settings || {},
isActive: true,
export const getIntegrationAccountForId = async (id: string) => {
return await prisma.integrationAccount.findUnique({
where: {
id,
},
});
};
@ -49,3 +32,13 @@ export const getIntegrationAccounts = async (userId: string) => {
},
});
};
export const getIntegrationAccountForSlug = async (slug: string) => {
return await prisma.integrationAccount.findFirst({
where: {
integrationDefinition: {
slug,
},
},
});
};

View File

@ -1,5 +1,5 @@
import { type OAuth2Params } from "@core/types";
import { IsBoolean, IsString } from "class-validator";
import { IsBoolean, IsOptional, IsString } from "class-validator";
import type { IntegrationDefinitionV2 } from "@core/database";
import { z } from "zod";
@ -25,12 +25,17 @@ export class OAuthBodyInterface {
@IsString()
integrationDefinitionId: string;
@IsString()
@IsOptional()
integrationAccountId?: string;
}
export const OAuthBodySchema = z.object({
redirectURL: z.string(),
integrationDefinitionId: z.string(),
mcp: z.boolean().optional().default(false),
integrationAccountId: z.string().optional(),
});
export type CallbackParams = Record<string, string>;

View File

@ -24,17 +24,20 @@ const MCP_CALLBACK_URL = `${CALLBACK_URL}/mcp`;
const session: Record<string, SessionRecord> = {};
const mcpSession: Record<
string,
{ integrationDefinitionId: string; redirectURL: string }
{
integrationDefinitionId: string;
redirectURL: string;
workspaceId: string;
userId: string;
integrationAccountId: string;
}
> = {};
export type CallbackParams = Record<string, string>;
// Remix-style callback handler
// Accepts a Remix LoaderFunctionArgs-like object: { request }
export async function callbackHandler(
params: CallbackParams,
request: Request,
) {
export async function callbackHandler(params: CallbackParams) {
if (!params.state) {
throw new Error("No state found");
}
@ -134,14 +137,14 @@ export async function callbackHandler(
...params,
redirect_uri: CALLBACK_URL,
},
integrationDefinition,
},
},
sessionRecord.userId,
sessionRecord.workspaceId,
);
await tasks.trigger<typeof scheduler>("scheduler", {
integrationAccountId: integrationAccount.id,
integrationAccountId: integrationAccount?.id,
});
return new Response(null, {
@ -253,7 +256,7 @@ export async function getRedirectURLForMCP(
userId: string,
workspaceId?: string,
) {
const { integrationDefinitionId } = oAuthBody;
const { integrationDefinitionId, integrationAccountId } = oAuthBody;
logger.info(
`We got OAuth request for ${workspaceId}: ${userId}: ${integrationDefinitionId}`,
@ -265,6 +268,10 @@ export async function getRedirectURLForMCP(
integrationDefinitionId,
);
if (!integrationAccountId) {
throw new Error("No integration account found");
}
if (!integrationDefinition) {
throw new Error("No integration definition found");
}
@ -290,6 +297,9 @@ export async function getRedirectURLForMCP(
mcpSession[state] = {
integrationDefinitionId: integrationDefinition.id,
redirectURL,
userId,
workspaceId: workspaceId as string,
integrationAccountId,
};
return {

View File

@ -42,7 +42,7 @@ export class WebhookService {
if (integrationDefinition) {
try {
const accountIdResponse = await runIntegrationTrigger(
const identifyResponse = await runIntegrationTrigger(
integrationDefinition,
{
event: IntegrationEventType.IDENTIFY,
@ -55,12 +55,39 @@ export class WebhookService {
let accountId: string | undefined;
if (
accountIdResponse?.message?.startsWith("The event payload type is")
) {
accountId = undefined;
// Handle new CLI message format response
if (identifyResponse?.success && identifyResponse?.result) {
// Check if there are identifiers in the response
if (
identifyResponse.result.identifiers &&
identifyResponse.result.identifiers.length > 0
) {
accountId = identifyResponse.result.identifiers[0].id;
} else if (
identifyResponse.result.activities &&
identifyResponse.result.activities.length > 0
) {
// Sometimes the account ID might be in activities data
const firstActivity = identifyResponse.result.activities[0];
accountId = firstActivity.accountId || firstActivity.id;
} else {
// Check raw output for backward compatibility
accountId = identifyResponse.rawOutput?.trim();
}
} else if (identifyResponse?.error) {
logger.warn("Integration IDENTIFY command failed", {
error: identifyResponse.error,
sourceName,
});
} else {
accountId = accountIdResponse;
// Handle legacy response format for backward compatibility
if (
identifyResponse?.message?.startsWith("The event payload type is")
) {
accountId = undefined;
} else {
accountId = identifyResponse;
}
}
if (accountId) {
@ -68,6 +95,17 @@ export class WebhookService {
where: { accountId },
include: { integrationDefinition: true },
});
logger.info("Found integration account for webhook", {
accountId,
integrationAccountId: integrationAccount?.id,
sourceName,
});
} else {
logger.warn("No account ID found from IDENTIFY command", {
sourceName,
response: identifyResponse,
});
}
} catch (error) {
logger.error("Failed to identify integration account", {
@ -85,22 +123,38 @@ export class WebhookService {
if (integrationAccount) {
try {
await runIntegrationTrigger(
logger.info(`Processing webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
integrationSlug: integrationAccount.integrationDefinition.slug,
});
const processResponse = await runIntegrationTrigger(
integrationAccount.integrationDefinition,
{
event: IntegrationEventType.PROCESS,
integrationAccount,
eventBody: {
eventHeaders,
eventData: { ...eventBody },
},
},
integrationAccount.integratedById,
integrationAccount.workspaceId,
integrationAccount,
);
logger.log(`Successfully processed webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
});
if (processResponse?.success) {
logger.log(`Successfully processed webhook for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
activitiesCreated: processResponse.result?.activities?.length || 0,
messagesProcessed: processResponse.messages?.length || 0,
});
} else {
logger.warn(`Webhook processing had issues for ${sourceName}`, {
integrationAccountId: integrationAccount.id,
error: processResponse?.error,
success: processResponse?.success,
});
}
} catch (error) {
logger.error(`Failed to process webhook for ${sourceName}`, {
error,

View File

@ -3,8 +3,6 @@ import { IntegrationEventType } from "@core/types";
import { logger, schedules, tasks } from "@trigger.dev/sdk/v3";
import { type integrationRun } from "./integration-run";
import { getOrCreatePersonalAccessToken } from "../utils/utils";
import { nanoid } from "nanoid";
const prisma = new PrismaClient();
@ -27,7 +25,7 @@ export const integrationRunSchedule = schedules.task({
if (!integrationAccount) {
const deletedSchedule = await schedules.del(externalId);
logger.info("No integration account found");
logger.info("No integration account found, deleting schedule");
return deletedSchedule;
}
@ -36,22 +34,20 @@ export const integrationRunSchedule = schedules.task({
return null;
}
const pat = await getOrCreatePersonalAccessToken({
name: `integration_scheduled_${nanoid(10)}`,
userId: integrationAccount.workspace.userId as string,
logger.info("Triggering scheduled integration run", {
integrationId: integrationAccount.integrationDefinition.id,
integrationSlug: integrationAccount.integrationDefinition.slug,
accountId: integrationAccount.id,
});
if (!pat || !pat.token) {
logger.info("No pat token found");
return null;
}
return await tasks.trigger<typeof integrationRun>("integration-run", {
event: IntegrationEventType.SYNC,
pat: pat.token,
patId: pat.id,
integrationAccount,
integrationDefinition: integrationAccount.integrationDefinition,
eventBody: {
scheduled: true,
scheduledAt: new Date().toISOString(),
},
});
},
});

View File

@ -1,100 +1,435 @@
import createLoadRemoteModule, {
createRequires,
} from "@paciolan/remote-module-loader";
import { logger, task } from "@trigger.dev/sdk/v3";
import axios from "axios";
import { spawn } from "child_process";
import {
writeFileSync,
unlinkSync,
mkdtempSync,
existsSync,
readFileSync,
} from "fs";
import { join, isAbsolute, resolve } from "path";
import { tmpdir } from "os";
import {
type IntegrationDefinitionV2,
type IntegrationAccount,
} from "@core/database";
import { deletePersonalAccessToken } from "../utils/utils";
import { type IntegrationEventType } from "@core/types";
import { IntegrationEventType, type Message } from "@core/types";
import { extractMessagesFromOutput } from "../utils/cli-message-handler";
import {
createActivities,
createIntegrationAccount,
saveIntegrationAccountState,
saveMCPConfig,
} from "../utils/message-utils";
const fetcher = async (url: string) => {
// Handle remote URLs with axios
const response = await axios.get(url);
return response.data;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadRemoteModule = async (requires: any) =>
createLoadRemoteModule({ fetcher, requires });
function createAxiosInstance(token: string) {
const instance = axios.create();
instance.interceptors.request.use((config) => {
// Check if URL starts with /api and doesn't have a full host
if (config.url?.startsWith("/api")) {
config.url = `${process.env.BACKEND_HOST}${config.url.replace("/api/", "/")}`;
}
if (
config.url?.includes(process.env.FRONTEND_HOST || "") ||
config.url?.includes(process.env.BACKEND_HOST || "")
) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
return instance;
/**
* Determines if a string is a URL.
*/
function isUrl(str: string): boolean {
try {
// Accepts http, https, file, etc.
const url = new URL(str);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getRequires = (axios: any) => createRequires({ axios });
/**
* Loads integration file from a URL or a local path.
*/
const loadIntegrationSource = async (source: string): Promise<string> => {
if (!source) {
throw new Error("Integration source is not provided");
}
// If it's a URL, fetch it
if (isUrl(source)) {
try {
const response = await axios.get(source);
return response.data;
} catch (error) {
throw new Error(
`Failed to fetch integration file from ${source}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
// Otherwise, treat as a local file path (absolute or relative)
let filePath = source;
if (!isAbsolute(filePath)) {
filePath = resolve(process.cwd(), filePath);
}
if (existsSync(filePath)) {
try {
return readFileSync(filePath, "utf8");
} catch (error) {
throw new Error(
`Failed to read integration file from path ${filePath}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
throw new Error(`Integration source is not found: ${source}`);
};
/**
* Executes integration CLI command with integration file
*/
const executeCLICommand = async (
integrationFile: string,
eventType: IntegrationEventType,
eventBody?: any,
config?: any,
integrationDefinition?: IntegrationDefinitionV2,
state?: any,
): Promise<string> => {
return new Promise((resolve, reject) => {
// Create temporary directory for the integration file
const tempDir = mkdtempSync(join(tmpdir(), "integration-"));
const integrationPath = join(tempDir, "integration.js");
try {
// Write integration file to temporary location
writeFileSync(integrationPath, integrationFile);
// Build command arguments based on event type and integration-cli spec
const args = [integrationPath];
switch (eventType) {
case IntegrationEventType.SETUP:
args.push("setup");
args.push("--event-body", JSON.stringify(eventBody || {}));
args.push(
"--integration-definition",
JSON.stringify(integrationDefinition || {}),
);
break;
case IntegrationEventType.IDENTIFY:
args.push("identify");
args.push("--webhook-data", JSON.stringify(eventBody || {}));
break;
case IntegrationEventType.PROCESS:
args.push("process");
args.push(
"--event-data",
JSON.stringify(eventBody?.eventData || eventBody || {}),
);
args.push("--config", JSON.stringify(config || {}));
break;
case IntegrationEventType.SYNC:
args.push("sync");
args.push("--config", JSON.stringify(config || {}));
args.push("--state", JSON.stringify(state || {}));
break;
default:
throw new Error(`Unsupported event type: ${eventType}`);
}
// Use node to execute the integration file
const childProcess = spawn("node", args, {
env: process.env,
cwd: tempDir,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
childProcess.stdout.on("data", (data) => {
stdout += data.toString();
});
childProcess.stderr.on("data", (data) => {
stderr += data.toString();
});
childProcess.on("close", (code) => {
try {
// Clean up temporary file
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
if (code === 0) {
resolve(stdout);
} else {
reject(
new Error(
`Integration CLI failed with exit code ${code}: ${stderr}`,
),
);
}
});
childProcess.on("error", (error) => {
try {
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
reject(error);
});
} catch (error) {
try {
unlinkSync(integrationPath);
} catch (cleanupError) {
logger.warn("Failed to cleanup temporary file", {
error: cleanupError,
});
}
reject(error);
}
});
};
async function handleActivityMessage(
messages: Message[],
integrationAccountId: string,
userId: string,
): Promise<any> {
return createActivities({ integrationAccountId, messages, userId });
}
async function handleStateMessage(
messages: Message[],
integrationAccountId: string,
): Promise<any> {
// TODO: Implement state message handling
return saveIntegrationAccountState({ messages, integrationAccountId });
}
async function handleIdentifierMessage(message: Message): Promise<any> {
return message.data;
}
async function handleAccountMessage(
messages: Message[],
integrationDefinition: IntegrationDefinitionV2,
workspaceId: string,
userId: string,
integrationAccountId: string,
): Promise<any> {
const message = messages[0];
const mcp = message.data.mcp;
if (mcp) {
return await saveMCPConfig({
integrationAccountId,
config: message.data.config,
});
}
// Handle only one messages since account gets created only for one
const {
data: { settings, config, accountId },
} = messages[0];
return await createIntegrationAccount({
integrationDefinitionId: integrationDefinition.id,
workspaceId,
settings,
config,
accountId,
userId,
});
}
/**
* Handles CLI messages array and performs necessary actions based on message types
*/
async function handleMessageResponse(
messages: Message[],
integrationDefinition: IntegrationDefinitionV2,
workspaceId: string,
userId: string,
integrationAccountId?: string,
): Promise<any> {
try {
logger.info("Handling CLI message response", {
integrationId: integrationDefinition.id,
messageCount: messages.length,
messageTypes: messages.map((m) => m.type),
});
// Group messages by type
const grouped: Record<string, Message[]> = {};
for (const message of messages) {
if (!grouped[message.type]) {
grouped[message.type] = [];
}
grouped[message.type].push(message);
}
// Handle "activity" messages
if (grouped["activity"]) {
return await handleActivityMessage(
grouped["activity"],
integrationAccountId as string,
userId,
);
}
// Handle "state" messages
if (grouped["state"]) {
return await handleStateMessage(
grouped["state"],
integrationAccountId as string,
);
}
// Handle "identifier" messages
if (grouped["identifier"]) {
return await handleIdentifierMessage(grouped["identifier"][0]);
}
// Handle "account" messages (these may involve Prisma writes)
if (grouped["account"]) {
return await handleAccountMessage(
grouped["account"],
integrationDefinition,
workspaceId,
userId,
integrationAccountId as string,
);
}
// Warn for unknown message types
for (const type of Object.keys(grouped)) {
if (!["activity", "state", "identifier", "account"].includes(type)) {
for (const message of grouped[type]) {
logger.warn("Unknown message type", {
messageType: type,
message,
});
}
}
}
} catch (error) {
logger.error("Failed to handle CLI message response", {
error: error instanceof Error ? error.message : "Unknown error",
integrationId: integrationDefinition.id,
messages,
});
throw error;
}
}
// Remove old event-based handlers as they are replaced by message-type handlers above
export const integrationRun = task({
id: "integration-run",
run: async ({
pat,
patId,
eventBody,
integrationAccount,
integrationDefinition,
event,
workspaceId,
userId,
}: {
pat: string;
patId: string;
// This is the event you want to pass to the integration
event: IntegrationEventType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventBody?: any;
integrationDefinition: IntegrationDefinitionV2;
integrationAccount?: IntegrationAccount;
workspaceId?: string;
userId?: string;
}) => {
const remoteModuleLoad = await loadRemoteModule(
getRequires(createAxiosInstance(pat)),
);
try {
logger.info(
`Starting integration run for ${integrationDefinition.slug}`,
{
event,
integrationId: integrationDefinition.id,
},
);
logger.info(
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
);
// Load the integration file from a URL or a local path
const integrationSource = integrationDefinition.url as string;
const integrationFile = await loadIntegrationSource(integrationSource);
logger.info(`Loaded integration file from ${integrationSource}`);
const integrationFunction = await remoteModuleLoad(
`${integrationDefinition.url}/${integrationDefinition.version}/index.cjs`,
);
// Prepare enhanced event body based on event type
let enhancedEventBody = eventBody;
// const integrationFunction = await remoteModuleLoad(
// `${integrationDefinition.url}`,
// );
// For SETUP events, include OAuth response and parameters
if (event === IntegrationEventType.SETUP) {
enhancedEventBody = {
...eventBody,
};
}
// Construct the proper IntegrationEventPayload structure
const integrationEventPayload = {
event,
eventBody: { ...eventBody, integrationDefinition },
config: integrationAccount?.integrationConfiguration || {},
};
// For PROCESS events, ensure eventData is properly structured
if (event === IntegrationEventType.PROCESS) {
enhancedEventBody = {
eventData: eventBody,
};
}
const result = await integrationFunction.run(integrationEventPayload);
logger.info(`Executing integration CLI`, {
event,
integrationId: integrationDefinition.id,
hasConfig: !!integrationAccount?.integrationConfiguration,
});
await deletePersonalAccessToken(patId);
const settings = integrationAccount?.settings as any;
logger.info("Personal access token deleted");
// Execute the CLI command using node
const output = await executeCLICommand(
integrationFile,
event,
enhancedEventBody,
integrationAccount?.integrationConfiguration,
integrationDefinition,
settings?.state,
);
return result;
logger.info("Integration CLI executed successfully");
// Process the output messages
const messages = extractMessagesFromOutput(output);
logger.info("Integration run completed", {
messageCount: messages.length,
messageTypes: messages.map((m) => m.type),
});
// Handle all CLI messages through the generic handler
return await handleMessageResponse(
messages,
integrationDefinition,
workspaceId as string,
userId as string,
integrationAccount?.id,
);
} catch (error) {
const errorMessage = `Integration run failed: ${error instanceof Error ? error.message : "Unknown error"}`;
logger.error(errorMessage, {
integrationId: integrationDefinition.id,
event,
error,
});
// For SETUP commands, we need to throw the error so OAuth callback can handle it
if (event === IntegrationEventType.SETUP) {
throw error;
}
// For other commands, return error in appropriate format
return {
error: errorMessage,
errors: [errorMessage],
};
}
},
});

View File

@ -0,0 +1,45 @@
import { type Message } from "@core/types";
/**
* Validates if a message has the correct structure
* @param message - Message to validate
* @returns True if valid, false otherwise
*/
export function isValidMessage(message: any): message is Message {
return (
typeof message === "object" &&
message !== null &&
typeof message.type === "string" &&
message.data !== undefined &&
["spec", "activity", "state", "identifier", "account"].includes(
message.type,
)
);
}
/**
* Extracts and validates messages from CLI output
* @param output - Raw CLI output string
* @returns Array of valid messages
*/
export function extractMessagesFromOutput(output: string): Message[] {
const messages: Message[] = [];
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (isValidMessage(parsed)) {
messages.push(parsed);
}
} catch (error) {
// Line is not JSON, skip it
continue;
}
}
return messages;
}

View File

@ -0,0 +1,130 @@
import { PrismaClient } from "@prisma/client";
import { type Message } from "@core/types";
const prisma = new PrismaClient();
export const createIntegrationAccount = async ({
integrationDefinitionId,
userId,
accountId,
config,
settings,
workspaceId,
}: {
integrationDefinitionId: string;
userId: string;
accountId: string;
workspaceId: string;
config?: Record<string, any>;
settings?: Record<string, any>;
}) => {
return prisma.integrationAccount.create({
data: {
accountId,
integrationDefinitionId,
integratedById: userId,
integrationConfiguration: config || {},
settings: settings || {},
isActive: true,
workspaceId,
},
});
};
export const saveMCPConfig = async ({
integrationAccountId,
config,
}: {
integrationAccountId: string;
config: any;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
if (!integrationAccount) {
return [];
}
const integrationConfig = integrationAccount.integrationConfiguration as any;
return prisma.integrationAccount.update({
where: {
id: integrationAccountId,
},
data: {
integrationConfiguration: {
...integrationConfig,
mcp: config,
},
},
});
};
export const saveIntegrationAccountState = async ({
messages,
integrationAccountId,
}: {
messages: Message[];
integrationAccountId: string;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
const settings = integrationAccount?.settings as any;
const state = settings.state;
return Promise.all(
messages.map(async (message) => {
return await prisma.integrationAccount.update({
where: {
id: integrationAccountId,
},
data: {
settings: {
...settings,
state: {
...state,
...message.data,
},
},
},
});
}),
);
};
export const createActivities = async ({
integrationAccountId,
messages,
}: {
integrationAccountId: string;
messages: Message[];
userId: string;
}) => {
const integrationAccount = await prisma.integrationAccount.findUnique({
where: {
id: integrationAccountId,
},
});
if (!integrationAccount) {
return [];
}
return await prisma.activity.createMany({
data: messages.map((message) => {
return {
text: message.data.text,
sourceURL: message.data.sourceURL,
integrationAccountId,
workspaceId: integrationAccount?.workspaceId,
};
}),
});
};

3
integrations/linear/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
bin
node_modules

View File

@ -1,19 +0,0 @@
import axios from 'axios';
export async function integrationCreate(data: any, integrationDefinition: any) {
const { api_key } = data;
const integrationConfiguration = {
api_key: api_key,
};
const payload = {
settings: {},
accountId: 'linear-account', // Linear doesn't have a specific account ID
config: integrationConfiguration,
integrationDefinitionId: integrationDefinition.id,
};
const integrationAccount = (await axios.post(`/api/v1/integration_account`, payload)).data;
return integrationAccount;
}

View File

@ -23,10 +23,6 @@
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-replace": "^5.0.7",
"@types/node": "^18.0.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
@ -37,10 +33,6 @@
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^3.4.2",
"rimraf": "^3.0.2",
"rollup": "^4.28.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"tslib": "^2.8.1",
"typescript": "^4.7.2",
"tsup": "^8.0.1",
@ -66,6 +58,6 @@
"commander": "^12.0.0",
"openai": "^4.0.0",
"react-query": "^3.39.3",
"@redplanethq/sdk": "0.1.0"
"@redplanethq/sdk": "0.1.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,71 +0,0 @@
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import nodePolyfills from 'rollup-plugin-node-polyfills';
import postcss from 'rollup-plugin-postcss';
import { babel } from '@rollup/plugin-babel';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
const frontendPlugins = [
postcss({
inject: true, // Inject CSS as JS, making it part of the bundle
minimize: true, // Minify CSS
}),
json(),
resolve({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.frontend.json',
}),
babel({
extensions: ['.js', '.jsx', '.ts', '.tsx'],
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
}),
terser(),
];
export default [
{
input: 'frontend/index.tsx',
external: ['react', 'react-dom', '@tegonhq/ui', 'axios', 'react-query'],
output: [
{
file: 'dist/frontend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
inlineDynamicImports: true,
},
],
plugins: frontendPlugins,
},
{
input: 'backend/index.ts',
external: ['axios'],
output: [
{
file: 'dist/backend/index.js',
sourcemap: true,
format: 'cjs',
exports: 'named',
preserveModules: false,
},
],
plugins: [
nodePolyfills(),
json(),
resolve({ extensions: ['.js', '.ts'] }),
commonjs({
include: /\/node_modules\//,
}),
typescript({
tsconfig: 'tsconfig.json',
}),
terser(),
],
},
];

View File

@ -0,0 +1,66 @@
export async function integrationCreate({ apiKey }: { apiKey: string }) {
// Fetch the Linear user info using the GraphQL API
const response = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: {
Authorization: `${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'query { viewer { id name email } }',
}),
});
if (!response.ok) {
throw new Error(`Failed to fetch Linear user: ${response.status} ${response.statusText}`);
}
const result = await response.json();
const viewer = result?.data?.viewer;
const userId = viewer?.id;
if (!userId) {
throw new Error('Could not extract userId from Linear GraphQL API response');
}
return [
{
type: 'account',
data: {
settings: {
user: {
id: viewer.id,
name: viewer.name,
email: viewer.email,
},
},
accountId: userId,
config: { apiKey },
},
},
];
}
interface MCPIntegrationCreateData {
oauthResponse: {
access_token: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
[key: string]: any;
};
mcp: boolean;
}
export async function integrationCreateForMCP(data: MCPIntegrationCreateData) {
return [
{
type: 'account',
data: {
mcp: true,
config: data.oauthResponse,
},
},
];
}

View File

@ -1,5 +1,5 @@
import { handleSchedule } from 'schedule';
import { integrationCreate } from './account-create';
import { handleSchedule } from './schedule';
import { integrationCreate, integrationCreateForMCP } from './account-create';
import {
IntegrationCLI,
@ -11,7 +11,9 @@ import {
export async function run(eventPayload: IntegrationEventPayload) {
switch (eventPayload.event) {
case IntegrationEventType.SETUP:
return await integrationCreate(eventPayload.eventBody, eventPayload.integrationDefinition);
return eventPayload.eventBody.mcp
? await integrationCreateForMCP(eventPayload.eventBody)
: await integrationCreate(eventPayload.eventBody);
case IntegrationEventType.SYNC:
return await handleSchedule(eventPayload.config);

View File

@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from 'axios';
import { IntegrationAccount } from '@redplanethq/sol-sdk';
interface LinearActivityCreateParams {
url: string;
@ -257,7 +256,7 @@ async function fetchRecentComments(accessToken: string, lastSyncTime: string) {
async function processIssueActivities(
issues: any[],
userId: string,
integrationAccount: IntegrationAccount,
integrationAccount: any,
isCreator: boolean = false,
) {
const activities = [];
@ -426,11 +425,7 @@ async function processIssueActivities(
/**
* Process comment activities and create appropriate activity records
*/
async function processCommentActivities(
comments: any[],
userId: string,
integrationAccount: IntegrationAccount,
) {
async function processCommentActivities(comments: any[], userId: string, integrationAccount: any) {
const activities = [];
for (const comment of comments) {
@ -513,7 +508,7 @@ function getDefaultSyncTime(): string {
/**
* Main function to handle scheduled sync for Linear integration
*/
export async function handleSchedule(integrationAccount: IntegrationAccount) {
export async function handleSchedule(integrationAccount: any) {
try {
const integrationConfiguration = integrationAccount.integrationConfiguration as any;
@ -594,6 +589,6 @@ export async function handleSchedule(integrationAccount: IntegrationAccount) {
/**
* The main handler for the scheduled sync event
*/
export async function scheduleHandler(integrationAccount: IntegrationAccount) {
export async function scheduleHandler(integrationAccount: any) {
return handleSchedule(integrationAccount);
}

View File

@ -1,32 +0,0 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"strictNullChecks": true,
"removeComments": true,
"preserveConstEnums": true,
"sourceMap": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["frontend/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -1,17 +1,19 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "backend",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": true,
"removeComments": true,
@ -25,7 +27,7 @@
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": false
},
"include": ["backend/**/*"],
"include": ["src/**/*"],
"exclude": ["node_modules", "build", "dist", "scripts", "acceptance-tests", "webpack", "jest"],
"types": ["typePatches"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from 'tsup';
import { dependencies } from './package.json';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs'], // or esm if you're using that
bundle: true,
target: 'node16',
outDir: 'bin',
splitting: false,
shims: true,
clean: true,
name: 'linear',
platform: 'node',
legacyOutput: false,
noExternal: Object.keys(dependencies || {}), // ⬅️ bundle all deps
treeshake: {
preset: 'recommended',
},
});

View File

@ -23,10 +23,6 @@
},
"devDependencies": {
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-replace": "^5.0.7",
"@types/node": "^18.0.20",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
@ -37,10 +33,6 @@
"eslint-plugin-unused-imports": "^2.0.0",
"prettier": "^3.4.2",
"rimraf": "^3.0.2",
"rollup": "^4.28.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"tslib": "^2.8.1",
"typescript": "^4.7.2",
"tsup": "^8.0.1",

View File

@ -1,7 +1,6 @@
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@ -9,16 +8,12 @@
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src",
"lib": ["dom", "dom.iterable", "esnext"],
"baseUrl": "frontend",
"allowJs": false,
"allowSyntheticDefaultImports": true,
"module": "esnext",
"moduleResolution": "node",
"isolatedModules": true,
"strictNullChecks": true,
"removeComments": true,

View File

@ -180,6 +180,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
authorizationUrl.searchParams.set("resource", this.authorizeResource);
}
// Keep it
console.log(this.authorizationUrl);
// Store the URL instead of opening browser

View File

@ -1,6 +1,6 @@
{
"name": "@redplanethq/sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "CORE Node.JS SDK",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",

View File

@ -1 +1 @@
export { IntegrationCLI } from './integration_cli';
export { IntegrationCLI } from './integration-cli';

View File

@ -56,7 +56,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error during setup:', error);
@ -83,7 +83,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error processing data:', error);
@ -105,7 +105,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error identifying account:', error);
@ -126,7 +126,7 @@ export abstract class IntegrationCLI {
data: spec,
};
// For spec, we keep the single message output for compatibility
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
} catch (error) {
console.error('Error getting spec:', error);
process.exit(1);
@ -153,7 +153,7 @@ export abstract class IntegrationCLI {
});
for (const message of messages) {
console.log(JSON.stringify(message, null, 2));
console.log(JSON.stringify(message));
}
} catch (error) {
console.error('Error during sync:', error);

View File

@ -56,7 +56,7 @@ export interface Identifier {
type?: string;
}
export type MessageType = "spec" | "activity" | "state" | "identifier";
export type MessageType = "spec" | "activity" | "state" | "identifier" | "account";
export interface Message {
type: MessageType;